fix(agent): harden persisted output handling

This commit is contained in:
Ubuntu 2026-06-27 09:23:46 +00:00
parent 3fccd9ab9a
commit c2b1453b2e
4 changed files with 76 additions and 19 deletions

View File

@ -615,6 +615,7 @@ class SessionManager:
the most recent writes.
"""
path = self._get_session_path(session.key)
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(".jsonl.tmp")
try:

View File

@ -290,7 +290,8 @@ def current_time_str(timezone: str | None = None) -> str:
_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]')
_TOOL_RESULT_PREVIEW_CHARS = 1200
_TOOL_RESULT_SUMMARY_MAX_EDGE_CHARS = 800
_TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS = 80
_TOOL_RESULTS_DIR = ".nanobot/tool-results"
_TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60
_TOOL_RESULT_MAX_BUCKETS = 32
@ -408,18 +409,59 @@ def _render_tool_result_reference(
filepath: Path,
*,
original_size: int,
preview: str,
truncated_preview: bool,
head: str,
tail: str | None,
omitted_middle_chars: int,
) -> str:
result = (
f"[tool output persisted]\n"
f"Full output saved to: {filepath}\n"
f"Original size: {original_size} chars\n"
f"Preview:\n{preview}"
lines = [
"[tool output persisted]",
f"tool_output_id: {filepath.stem}",
f"original_size_chars: {original_size}",
"storage: internal audit artifact",
(
"guidance: Use this head/tail summary first. Avoid reading "
"persisted tool-output files wholesale; rerun a narrower command "
"when more detail is needed."
),
"head:",
head,
]
if tail is not None:
lines.extend([
f"... omitted_middle_chars: {omitted_middle_chars}",
"tail:",
tail,
])
return "\n".join(lines)
def _build_tool_result_reference(filepath: Path, text: str, *, max_chars: int) -> str:
edge_chars = min(
_TOOL_RESULT_SUMMARY_MAX_EDGE_CHARS,
max(_TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS, max_chars // 3),
)
if truncated_preview:
result += "\n...\n(Read the saved file if you need the full output.)"
return result
while True:
head = text[:edge_chars]
if len(text) > edge_chars * 2:
tail: str | None = text[-edge_chars:]
omitted_middle_chars = len(text) - len(head) - len(tail)
else:
tail = None
omitted_middle_chars = 0
result = _render_tool_result_reference(
filepath,
original_size=len(text),
head=head,
tail=tail,
omitted_middle_chars=omitted_middle_chars,
)
if len(result) <= max_chars or edge_chars <= _TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS:
return truncate_text(result, max_chars)
overflow = len(result) - max_chars
edge_chars = max(
_TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS,
edge_chars - max(overflow // 2 + 1, 16),
)
def _bucket_mtime(path: Path) -> float:
@ -494,13 +536,7 @@ def maybe_persist_tool_result(
else:
_write_text_atomic(path, text_payload)
preview = text_payload[:_TOOL_RESULT_PREVIEW_CHARS]
return _render_tool_result_reference(
path,
original_size=len(text_payload),
preview=preview,
truncated_preview=len(text_payload) > _TOOL_RESULT_PREVIEW_CHARS,
)
return _build_tool_result_reference(path, text_payload, max_chars=max_chars)
def split_message(content: str, max_len: int = 2000) -> list[str]:

View File

@ -48,7 +48,13 @@ async def test_runner_persists_large_tool_results_for_follow_up_calls(tmp_path):
assert result.final_content == "done"
tool_message = next(msg for msg in captured_second_call if msg.get("role") == "tool")
assert "[tool output persisted]" in tool_message["content"]
assert "tool-results" in tool_message["content"]
assert "tool_output_id: call_big" in tool_message["content"]
assert "original_size_chars: 20000" in tool_message["content"]
assert "head:" in tool_message["content"]
assert "tail:" in tool_message["content"]
assert "Read the saved file" not in tool_message["content"]
assert str(tmp_path) not in tool_message["content"]
assert len(tool_message["content"]) <= 2048
assert (tmp_path / ".nanobot" / "tool-results" / "test_runner" / "call_big.txt").exists()
@ -76,6 +82,8 @@ def test_persist_tool_result_prunes_old_session_buckets(tmp_path):
)
assert "[tool output persisted]" in persisted
assert "tool_output_id: call_big" in persisted
assert "tool-results" not in persisted
assert not old_bucket.exists()
assert recent_bucket.exists()
assert (root / "current_session" / "call_big.txt").exists()

View File

@ -1,6 +1,7 @@
"""Tests for atomic session save and corrupt-file repair."""
import json
import shutil
from datetime import datetime
from pathlib import Path
@ -36,6 +37,17 @@ class TestAtomicSave:
tmp_files = list(mgr.sessions_dir.glob("*.tmp"))
assert tmp_files == []
def test_save_recreates_deleted_sessions_dir(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
shutil.rmtree(mgr.sessions_dir)
session = Session(key="test:recreate")
session.add_message("user", "hello")
mgr.save(session)
path = mgr._get_session_path("test:recreate")
assert path.exists()
def test_tmp_file_cleaned_up_on_write_failure(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
session = Session(key="test:fail")