diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 1d6ae7c09..01be3c2e2 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -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: diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index a0577dca5..f6c91022c 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -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]: diff --git a/tests/agent/test_runner_persistence.py b/tests/agent/test_runner_persistence.py index 24ac10518..4a54c7c67 100644 --- a/tests/agent/test_runner_persistence.py +++ b/tests/agent/test_runner_persistence.py @@ -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() diff --git a/tests/agent/test_session_atomic.py b/tests/agent/test_session_atomic.py index 1fe5b9caa..66291e4b5 100644 --- a/tests/agent/test_session_atomic.py +++ b/tests/agent/test_session_atomic.py @@ -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")