Merge pull request #1280 from NousResearch/hermes/hermes-de3d4e49-pr944
fix: make session log writes reuse shared atomic JSON helper
This commit is contained in:
@@ -100,6 +100,7 @@ from agent.trajectory import (
|
|||||||
convert_scratchpad_to_think, has_incomplete_scratchpad,
|
convert_scratchpad_to_think, has_incomplete_scratchpad,
|
||||||
save_trajectory as _save_trajectory_to_file,
|
save_trajectory as _save_trajectory_to_file,
|
||||||
)
|
)
|
||||||
|
from utils import atomic_json_write
|
||||||
|
|
||||||
HONCHO_TOOL_NAMES = {
|
HONCHO_TOOL_NAMES = {
|
||||||
"honcho_context",
|
"honcho_context",
|
||||||
@@ -1398,8 +1399,12 @@ class AIAgent:
|
|||||||
"messages": cleaned,
|
"messages": cleaned,
|
||||||
}
|
}
|
||||||
|
|
||||||
with open(self.session_log_file, "w", encoding="utf-8") as f:
|
atomic_json_write(
|
||||||
json.dump(entry, f, indent=2, ensure_ascii=False, default=str)
|
self.session_log_file,
|
||||||
|
entry,
|
||||||
|
indent=2,
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if self.verbose_logging:
|
if self.verbose_logging:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Shared fixtures for the hermes-agent test suite."""
|
"""Shared fixtures for the hermes-agent test suite."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
@@ -59,6 +60,39 @@ def mock_config():
|
|||||||
def _timeout_handler(signum, frame):
|
def _timeout_handler(signum, frame):
|
||||||
raise TimeoutError("Test exceeded 30 second timeout")
|
raise TimeoutError("Test exceeded 30 second timeout")
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _ensure_current_event_loop(request):
|
||||||
|
"""Provide a default event loop for sync tests that call get_event_loop().
|
||||||
|
|
||||||
|
Python 3.11+ no longer guarantees a current loop for plain synchronous tests.
|
||||||
|
A number of gateway tests still use asyncio.get_event_loop().run_until_complete(...).
|
||||||
|
Ensure they always have a usable loop without interfering with pytest-asyncio's
|
||||||
|
own loop management for @pytest.mark.asyncio tests.
|
||||||
|
"""
|
||||||
|
if request.node.get_closest_marker("asyncio") is not None:
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop_policy().get_event_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
loop = None
|
||||||
|
|
||||||
|
created = loop is None or loop.is_closed()
|
||||||
|
if created:
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if created and loop is not None:
|
||||||
|
try:
|
||||||
|
loop.close()
|
||||||
|
finally:
|
||||||
|
asyncio.set_event_loop(None)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _enforce_test_timeout():
|
def _enforce_test_timeout():
|
||||||
"""Kill any individual test that takes longer than 30 seconds."""
|
"""Kill any individual test that takes longer than 30 seconds."""
|
||||||
|
|||||||
@@ -97,6 +97,17 @@ class TestAtomicJsonWrite:
|
|||||||
text = target.read_text()
|
text = target.read_text()
|
||||||
assert ' "a"' in text # 4-space indent
|
assert ' "a"' in text # 4-space indent
|
||||||
|
|
||||||
|
def test_accepts_json_dump_default_hook(self, tmp_path):
|
||||||
|
class CustomValue:
|
||||||
|
def __str__(self):
|
||||||
|
return "custom-value"
|
||||||
|
|
||||||
|
target = tmp_path / "custom_default.json"
|
||||||
|
atomic_json_write(target, {"value": CustomValue()}, default=str)
|
||||||
|
|
||||||
|
result = json.loads(target.read_text(encoding="utf-8"))
|
||||||
|
assert result == {"value": "custom-value"}
|
||||||
|
|
||||||
def test_unicode_content(self, tmp_path):
|
def test_unicode_content(self, tmp_path):
|
||||||
target = tmp_path / "unicode.json"
|
target = tmp_path / "unicode.json"
|
||||||
data = {"emoji": "🎉", "japanese": "日本語"}
|
data = {"emoji": "🎉", "japanese": "日本語"}
|
||||||
|
|||||||
@@ -1928,6 +1928,24 @@ class TestSafeWriter:
|
|||||||
assert inner.getvalue() == "test"
|
assert inner.getvalue() == "test"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveSessionLogAtomicWrite:
|
||||||
|
def test_uses_shared_atomic_json_helper(self, agent, tmp_path):
|
||||||
|
agent.session_log_file = tmp_path / "session.json"
|
||||||
|
messages = [{"role": "user", "content": "hello"}]
|
||||||
|
|
||||||
|
with patch("run_agent.atomic_json_write", create=True) as mock_atomic_write:
|
||||||
|
agent._save_session_log(messages)
|
||||||
|
|
||||||
|
mock_atomic_write.assert_called_once()
|
||||||
|
call_args = mock_atomic_write.call_args
|
||||||
|
assert call_args.args[0] == agent.session_log_file
|
||||||
|
payload = call_args.args[1]
|
||||||
|
assert payload["session_id"] == agent.session_id
|
||||||
|
assert payload["messages"] == messages
|
||||||
|
assert call_args.kwargs["indent"] == 2
|
||||||
|
assert call_args.kwargs["default"] is str
|
||||||
|
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# Anthropic adapter integration fixes
|
# Anthropic adapter integration fixes
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
|
|||||||
20
utils.py
20
utils.py
@@ -9,17 +9,25 @@ from typing import Any, Union
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
def atomic_json_write(path: Union[str, Path], data: Any, *, indent: int = 2) -> None:
|
def atomic_json_write(
|
||||||
|
path: Union[str, Path],
|
||||||
|
data: Any,
|
||||||
|
*,
|
||||||
|
indent: int = 2,
|
||||||
|
**dump_kwargs: Any,
|
||||||
|
) -> None:
|
||||||
"""Write JSON data to a file atomically.
|
"""Write JSON data to a file atomically.
|
||||||
|
|
||||||
Uses temp file + fsync + os.replace to ensure the target file is never
|
Uses temp file + fsync + os.replace to ensure the target file is never
|
||||||
left in a partially-written state. If the process crashes mid-write,
|
left in a partially-written state. If the process crashes mid-write,
|
||||||
the previous version of the file remains intact.
|
the previous version of the file remains intact.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: Target file path (will be created or overwritten).
|
path: Target file path (will be created or overwritten).
|
||||||
data: JSON-serializable data to write.
|
data: JSON-serializable data to write.
|
||||||
indent: JSON indentation (default 2).
|
indent: JSON indentation (default 2).
|
||||||
|
**dump_kwargs: Additional keyword args forwarded to json.dump(), such
|
||||||
|
as default=str for non-native types.
|
||||||
"""
|
"""
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -31,7 +39,13 @@ def atomic_json_write(path: Union[str, Path], data: Any, *, indent: int = 2) ->
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
json.dump(data, f, indent=indent, ensure_ascii=False)
|
json.dump(
|
||||||
|
data,
|
||||||
|
f,
|
||||||
|
indent=indent,
|
||||||
|
ensure_ascii=False,
|
||||||
|
**dump_kwargs,
|
||||||
|
)
|
||||||
f.flush()
|
f.flush()
|
||||||
os.fsync(f.fileno())
|
os.fsync(f.fileno())
|
||||||
os.replace(tmp_path, path)
|
os.replace(tmp_path, path)
|
||||||
|
|||||||
Reference in New Issue
Block a user