From 363a0704dbc8cb11f40bd7eef754ad744fefa263 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Fri, 10 Apr 2026 04:46:48 +0000 Subject: [PATCH] refactor(runner): update message processing to preserve historical context - Adjusted message handling in AgentRunner to ensure that historical messages remain unchanged during context governance. - Introduced tests to verify that backfill operations do not alter the saved message boundary, maintaining the integrity of the conversation history. --- nanobot/agent/runner.py | 12 ++- tests/agent/test_runner.py | 163 +++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 4 deletions(-) diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index bc1a26aba..e90715375 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -101,10 +101,14 @@ class AgentRunner: for iteration in range(spec.max_iterations): try: - messages = self._backfill_missing_tool_results(messages) - messages = self._microcompact(messages) - messages = self._apply_tool_result_budget(spec, messages) - messages_for_model = self._snip_history(spec, messages) + # Keep the persisted conversation untouched. Context governance + # may repair or compact historical messages for the model, but + # those synthetic edits must not shift the append boundary used + # later when the caller saves only the new turn. + messages_for_model = self._backfill_missing_tool_results(messages) + messages_for_model = self._microcompact(messages_for_model) + messages_for_model = self._apply_tool_result_budget(spec, messages_for_model) + messages_for_model = self._snip_history(spec, messages_for_model) except Exception as exc: logger.warning( "Context governance failed on turn {} for {}: {}; using raw messages", diff --git a/tests/agent/test_runner.py b/tests/agent/test_runner.py index afb06634f..a298ed956 100644 --- a/tests/agent/test_runner.py +++ b/tests/agent/test_runner.py @@ -1239,6 +1239,169 @@ async def test_backfill_noop_when_complete(): assert result is messages # same object — no copy +@pytest.mark.asyncio +async def test_backfill_repairs_model_context_without_shifting_save_turn_boundary(tmp_path): + """Historical backfill should not duplicate old tail messages on persist.""" + from nanobot.agent.loop import AgentLoop + from nanobot.agent.runner import _BACKFILL_CONTENT + from nanobot.bus.events import InboundMessage + from nanobot.bus.queue import MessageBus + + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + response = LLMResponse(content="new answer", tool_calls=[], usage={}) + provider.chat_with_retry = AsyncMock(return_value=response) + provider.chat_stream_with_retry = AsyncMock(return_value=response) + + loop = AgentLoop( + bus=MessageBus(), + provider=provider, + workspace=tmp_path, + model="test-model", + ) + loop.tools.get_definitions = MagicMock(return_value=[]) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] + + session = loop.sessions.get_or_create("cli:test") + session.messages = [ + {"role": "user", "content": "old user", "timestamp": "2026-01-01T00:00:00"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_missing", + "type": "function", + "function": {"name": "read_file", "arguments": "{}"}, + } + ], + "timestamp": "2026-01-01T00:00:01", + }, + {"role": "assistant", "content": "old tail", "timestamp": "2026-01-01T00:00:02"}, + ] + loop.sessions.save(session) + + result = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="test", content="new prompt") + ) + + assert result is not None + assert result.content == "new answer" + + request_messages = provider.chat_with_retry.await_args.kwargs["messages"] + synthetic = [ + message + for message in request_messages + if message.get("role") == "tool" and message.get("tool_call_id") == "call_missing" + ] + assert len(synthetic) == 1 + assert synthetic[0]["content"] == _BACKFILL_CONTENT + + session_after = loop.sessions.get_or_create("cli:test") + assert [ + { + key: value + for key, value in message.items() + if key in {"role", "content", "tool_call_id", "name", "tool_calls"} + } + for message in session_after.messages + ] == [ + {"role": "user", "content": "old user"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_missing", + "type": "function", + "function": {"name": "read_file", "arguments": "{}"}, + } + ], + }, + {"role": "assistant", "content": "old tail"}, + {"role": "user", "content": "new prompt"}, + {"role": "assistant", "content": "new answer"}, + ] + + +@pytest.mark.asyncio +async def test_runner_backfill_only_mutates_model_context_not_returned_messages(): + """Runner should repair orphaned tool calls for the model without rewriting result.messages.""" + from nanobot.agent.runner import AgentRunSpec, AgentRunner, _BACKFILL_CONTENT + + provider = MagicMock() + captured_messages: list[dict] = [] + + async def chat_with_retry(*, messages, **kwargs): + captured_messages[:] = messages + return LLMResponse(content="done", tool_calls=[], usage={}) + + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + + initial_messages = [ + {"role": "system", "content": "system"}, + {"role": "user", "content": "old user"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_missing", + "type": "function", + "function": {"name": "read_file", "arguments": "{}"}, + } + ], + }, + {"role": "assistant", "content": "old tail"}, + {"role": "user", "content": "new prompt"}, + ] + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=initial_messages, + tools=tools, + model="test-model", + max_iterations=3, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + )) + + synthetic = [ + message + for message in captured_messages + if message.get("role") == "tool" and message.get("tool_call_id") == "call_missing" + ] + assert len(synthetic) == 1 + assert synthetic[0]["content"] == _BACKFILL_CONTENT + + assert [ + { + key: value + for key, value in message.items() + if key in {"role", "content", "tool_call_id", "name", "tool_calls"} + } + for message in result.messages + ] == [ + {"role": "system", "content": "system"}, + {"role": "user", "content": "old user"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_missing", + "type": "function", + "function": {"name": "read_file", "arguments": "{}"}, + } + ], + }, + {"role": "assistant", "content": "old tail"}, + {"role": "user", "content": "new prompt"}, + {"role": "assistant", "content": "done"}, + ] + + # --------------------------------------------------------------------------- # Microcompact (stale tool result compaction) # ---------------------------------------------------------------------------