From bb3b449e09bc791030ed49a37fe90525130b9784 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 10 Jul 2026 13:57:20 +0800 Subject: [PATCH] refactor(agent): capture original user text per turn --- nanobot/agent/loop.py | 10 ++++++ nanobot/agent/tools/context.py | 1 + tests/agent/test_loop_tool_context.py | 47 +++++++++++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 7d9d9dac..5b840a39 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -113,6 +113,7 @@ class TurnContext: session_key: str state: TurnState turn_id: str + original_user_text: str | None = None session: Session | None = None history: list[dict[str, Any]] = field(default_factory=list) @@ -739,6 +740,7 @@ class AgentLoop: message_id: str | None = None, metadata: dict[str, Any] | None = None, session_key: str | None = None, + original_user_text: str | None = None, pending_queue: asyncio.Queue | None = None, ephemeral: bool = False, run_extra_hooks_for_ephemeral: bool = False, @@ -858,6 +860,7 @@ class AgentLoop: chat_id=chat_id, message_id=message_id, session_key=active_session_key, + original_user_text=original_user_text, metadata=dict(metadata or {}), ) file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key)) @@ -1283,6 +1286,7 @@ class AgentLoop: message_id=msg.metadata.get("message_id"), metadata=msg.metadata, session_key=key, + original_user_text=None, pending_queue=pending_queue, hook_factories=hook_factories, ) @@ -1350,6 +1354,11 @@ class AgentLoop: session_key=key, state=TurnState.RESTORE, turn_id=f"{key}:{time.time_ns()}", + original_user_text=( + None + if turn_continuation.internal_continuation_inbound(msg.metadata) + else msg.content + ), turn_wall_started_at=t0, visible_run_started_at=turn_continuation.internal_continuation_run_started_at( msg.metadata, @@ -1587,6 +1596,7 @@ class AgentLoop: message_id=ctx.msg.metadata.get("message_id"), metadata=ctx.msg.metadata, session_key=ctx.session_key, + original_user_text=ctx.original_user_text, pending_queue=ctx.pending_queue, ephemeral=ctx.ephemeral, run_extra_hooks_for_ephemeral=ctx.run_extra_hooks_for_ephemeral, diff --git a/nanobot/agent/tools/context.py b/nanobot/agent/tools/context.py index 61981618..054801bd 100644 --- a/nanobot/agent/tools/context.py +++ b/nanobot/agent/tools/context.py @@ -18,6 +18,7 @@ class RequestContext: chat_id: str message_id: str | None = None session_key: str | None = None + original_user_text: str | None = None metadata: dict[str, Any] = field(default_factory=dict) diff --git a/tests/agent/test_loop_tool_context.py b/tests/agent/test_loop_tool_context.py index 31b0f9cb..44b3b353 100644 --- a/tests/agent/test_loop_tool_context.py +++ b/tests/agent/test_loop_tool_context.py @@ -11,8 +11,10 @@ from nanobot.agent.tools.context import ( current_request_context, reset_request_context, ) +from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus from nanobot.providers.base import LLMResponse, ToolCallRequest +from nanobot.session.turn_continuation import INTERNAL_CONTINUATION_META class _ContextRecordingTool: @@ -165,6 +167,7 @@ async def test_agent_loop_restores_outer_request_context_after_runner_exception( assert current.channel == "slack" assert current.chat_id == "C123" assert current.session_key == "slack:C123:111.222" + assert current.original_user_text == " unchanged user text " raise RuntimeError("runner failed") loop.runner.run = AsyncMock(side_effect=fail_run) @@ -176,9 +179,53 @@ async def test_agent_loop_restores_outer_request_context_after_runner_exception( channel="slack", chat_id="C123", session_key="slack:C123:111.222", + original_user_text=" unchanged user text ", ) assert current_request_context() is outer finally: reset_request_context(outer_token) assert current_request_context() is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("metadata", "expected"), + [ + ({}, " original user text "), + ({INTERNAL_CONTINUATION_META: True}, None), + ], +) +async def test_process_message_captures_original_text_before_restore( + tmp_path: Path, + metadata: dict, + expected: str | None, +) -> None: + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + loop = AgentLoop( + bus=MessageBus(), + provider=provider, + workspace=tmp_path, + model="test-model", + ) + seen: list[str | None] = [] + + async def stop_after_capture(ctx) -> str: + seen.append(ctx.original_user_text) + raise RuntimeError("captured before restore") + + loop._state_restore = stop_after_capture # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="captured before restore"): + await loop._process_message( + InboundMessage( + channel="slack", + sender_id="user", + chat_id="C123", + content=" original user text ", + metadata=metadata, + ) + ) + + assert seen == [expected]