diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 6b854a9a8..83438dbef 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -69,6 +69,8 @@ _COMPACTABLE_TOOLS = frozenset({ "read_file", "exec", "grep", "find_files", "web_search", "web_fetch", "list_dir", "list_exec_sessions", }) +# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops. +_TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"}) _BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]" # Backward-compatible module attribute for tests/extensions that monkeypatch @@ -1114,6 +1116,9 @@ class AgentRunner: result: Any, ) -> Any: result = ensure_nonempty_tool_result(tool_name, result) + if tool_name in _TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS: + # Exempt tools bound their own output; skip generic offload and truncation. + return result try: content = maybe_persist_tool_result( spec.workspace, diff --git a/tests/agent/test_runner_persistence.py b/tests/agent/test_runner_persistence.py index d2bcfa9d4..3c9431751 100644 --- a/tests/agent/test_runner_persistence.py +++ b/tests/agent/test_runner_persistence.py @@ -123,6 +123,54 @@ def test_persist_tool_result_logs_cleanup_failures(monkeypatch, tmp_path): assert "[tool output persisted]" in persisted assert warnings and "Failed to clean stale tool result buckets" in warnings[0] + + +async def test_read_file_result_is_not_offloaded(tmp_path): + """read_file must not trigger generic offloading (prevents persist->read->persist loops).""" + from nanobot.agent.runner import AgentRunner, AgentRunSpec + + provider = MagicMock() + captured_second_call: list[dict] = [] + call_count = {"n": 0} + + async def chat_with_retry(*, messages, **kwargs): + call_count["n"] += 1 + if call_count["n"] == 1: + return LLMResponse( + content="reading", + tool_calls=[ToolCallRequest(id="call_rf", name="read_file", arguments={"path": "big.txt"})], + usage={"prompt_tokens": 5, "completion_tokens": 3}, + ) + captured_second_call[:] = messages + return LLMResponse(content="done", tool_calls=[], usage={}) + + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + tools.execute = AsyncMock(return_value="x" * 20_000) + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[{"role": "user", "content": "read big file"}], + tools=tools, + model="test-model", + max_iterations=2, + workspace=tmp_path, + session_key="test:runner", + max_tool_result_chars=2048, + )) + + assert result.final_content == "done" + tool_message = next(msg for msg in captured_second_call if msg.get("role") == "tool") + # read_file result must NOT be offloaded to a file + assert "[tool output persisted]" not in tool_message["content"] + # read_file manages its own size; generic truncation must NOT apply + assert len(tool_message["content"]) == 20_000 + # no file should have been written for this read_file call + offload_dir = tmp_path / ".nanobot" / "tool-results" + assert not any(offload_dir.rglob("call_rf.txt")) if offload_dir.exists() else True + + async def test_runner_keeps_going_when_tool_result_persistence_fails(): from nanobot.agent.runner import AgentRunSpec, AgentRunner