From b51d4780abefb4c61d50903350123dbe807cc545 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Tue, 21 Jul 2026 22:42:27 +0800 Subject: [PATCH] core/agent: refresh synthetic_tool todo nags to live stack --- src/plyngent/agent/chat.py | 4 + src/plyngent/agent/loop.py | 10 ++- src/plyngent/agent/todo_nag.py | 53 +++++++++++- tests/test_agent/test_todo_stack.py | 120 ++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 2 deletions(-) diff --git a/src/plyngent/agent/chat.py b/src/plyngent/agent/chat.py index 68cbb90..b52e38d 100644 --- a/src/plyngent/agent/chat.py +++ b/src/plyngent/agent/chat.py @@ -23,6 +23,7 @@ from .todo_nag import ( DEFAULT_TODO_NAG_STRATEGY, inject_todo_nag_for_stack_with_events, parse_todo_nag_strategy, + refresh_synthetic_todo_nags, ) from .usage import TokenUsage @@ -304,6 +305,9 @@ class ChatAgent: user_index = self._user_index(user_msg) if self.todo_stack is not None: self.todo_stack.begin_turn() + # Keep forged synthetic_tool nags aligned with the live stack so a + # previously dirty stack does not re-surface after it was cleaned. + _ = refresh_synthetic_todo_nags(self.messages, self.todo_stack) completed = False turn_usage = TokenUsage() diff --git a/src/plyngent/agent/loop.py b/src/plyngent/agent/loop.py index ceb735b..26e5547 100644 --- a/src/plyngent/agent/loop.py +++ b/src/plyngent/agent/loop.py @@ -37,7 +37,11 @@ from .events import ( ToolResultEvent, UsageEvent, ) -from .todo_nag import DEFAULT_TODO_NAG_STRATEGY, inject_todo_nag_for_stack_with_events +from .todo_nag import ( + DEFAULT_TODO_NAG_STRATEGY, + inject_todo_nag_for_stack_with_events, + refresh_synthetic_todo_nags, +) from .usage import resolve_round_usage, token_usage_from_api if TYPE_CHECKING: @@ -349,12 +353,16 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop while True: while rounds_used < allowance: rounds_used += 1 + # Request copy: shrink old tool dumps, then rewrite forged todo nags + # so cleaned stacks do not re-appear with stale OPEN WORK text. request_messages = compact_messages_for_request( messages, max_tokens=max_context_tokens, prompt_tokens_hint=prompt_tokens_hint, sent_estimate_tokens=sent_estimate_tokens, ) + if todo_stack is not None: + _ = refresh_synthetic_todo_nags(request_messages, todo_stack) sent_est = estimate_messages_tokens(request_messages) param = ChatCompletionsParam( messages=request_messages, diff --git a/src/plyngent/agent/todo_nag.py b/src/plyngent/agent/todo_nag.py index afe79c7..5c42b8c 100644 --- a/src/plyngent/agent/todo_nag.py +++ b/src/plyngent/agent/todo_nag.py @@ -29,6 +29,8 @@ type TodoNagKind = Literal["turn_start", "end_of_turn"] TODO_NAG_STRATEGIES: frozenset[str] = frozenset({"developer", "user", "synthetic_tool", "none"}) DEFAULT_TODO_NAG_STRATEGY: TodoNagStrategy = "developer" _SYNTHETIC_TOOL_NAME = "todo_list" +# Forged call ids from :func:`_append_synthetic_todo_list` (not model-authored). +_SYNTHETIC_CALL_ID_PREFIX = "todo-nag-" def parse_todo_nag_strategy(raw: str | None) -> TodoNagStrategy: @@ -63,7 +65,7 @@ def synthetic_todo_list_result(stack: TodoStack) -> str: def _append_synthetic_todo_list(messages: list[AnyChatMessage], body: str) -> str: """Append forged todo_list call + result. Returns the synthetic tool_call id.""" - call_id = f"todo-nag-{uuid.uuid4().hex[:12]}" + call_id = f"{_SYNTHETIC_CALL_ID_PREFIX}{uuid.uuid4().hex[:12]}" messages.append( AssistantChatMessage( content=UNSET, @@ -82,6 +84,55 @@ def _append_synthetic_todo_list(messages: list[AnyChatMessage], body: str) -> st return call_id +def is_synthetic_todo_nag_call_id(call_id: str) -> bool: + """True for forged ``todo_list`` nag tool_call ids (not model-authored).""" + return call_id.startswith(_SYNTHETIC_CALL_ID_PREFIX) + + +def refresh_synthetic_todo_nags( + messages: list[AnyChatMessage], + stack: TodoStack, +) -> int: + """Rewrite forged ``todo_list`` nag results to the live stack render. + + Synthetic nags are append-only snapshots. After the stack is cleaned (or + otherwise mutated), older nag results still sit in history and re-surface + on later model requests with stale OPEN WORK. Call this on a **request + copy** (not necessarily durable history) before each completion so the + model always sees the current stack for forged nags. + + Real model-authored ``todo_list`` results are left unchanged. + Returns the number of tool messages updated. + """ + body = stack.render() + synth_ids: set[str] = set() + for msg in messages: + if not isinstance(msg, AssistantChatMessage): + continue + tool_calls = msg.tool_calls + if tool_calls is UNSET or not tool_calls: + continue + for call in tool_calls: + if ( + isinstance(call, AssistantFunctionToolCall) + and is_synthetic_todo_nag_call_id(call.id) + and call.function.name == _SYNTHETIC_TOOL_NAME + ): + synth_ids.add(call.id) + + updated = 0 + for index, msg in enumerate(messages): + if not isinstance(msg, ToolChatMessage): + continue + if msg.tool_call_id not in synth_ids and not is_synthetic_todo_nag_call_id(msg.tool_call_id): + continue + if msg.content == body: + continue + messages[index] = ToolChatMessage(tool_call_id=msg.tool_call_id, content=body) + updated += 1 + return updated + + def inject_todo_nag( messages: list[AnyChatMessage], body: str, diff --git a/tests/test_agent/test_todo_stack.py b/tests/test_agent/test_todo_stack.py index d68dddd..5e48e06 100644 --- a/tests/test_agent/test_todo_stack.py +++ b/tests/test_agent/test_todo_stack.py @@ -497,3 +497,123 @@ async def test_loop_none_nag_strategy_skips_inject() -> None: ) finally: set_todo_stack(None) + + +def test_refresh_synthetic_todo_nags_updates_stale_results() -> None: + """Forged nags keep call ids; results track the live stack (not a frozen dirty snapshot).""" + from plyngent.agent.todo_nag import ( + inject_todo_nag_for_stack, + is_synthetic_todo_nag_call_id, + refresh_synthetic_todo_nags, + ) + + stack = TodoStack() + _ = stack.push("stale dirty item") + messages: list[AnyChatMessage] = [] + assert inject_todo_nag_for_stack(messages, stack, kind="end_of_turn", strategy="synthetic_tool") + assert any( + isinstance(m, ToolChatMessage) + and "stale dirty item" in m.content + and is_synthetic_todo_nag_call_id(m.tool_call_id) + for m in messages + ) + + _ = stack.clear() + n = refresh_synthetic_todo_nags(messages, stack) + assert n >= 1 + for m in messages: + if isinstance(m, ToolChatMessage) and is_synthetic_todo_nag_call_id(m.tool_call_id): + assert "stale dirty item" not in m.content + assert "empty" in m.content.lower() + + +@pytest.mark.asyncio +async def test_loop_synthetic_tool_refreshes_after_stack_cleared() -> None: + """After a dirty stack is cleaned, later turns must not re-show old nag text.""" + + class CaptureClient: + def __init__(self) -> None: + self.calls = 0 + self.payloads: list[list[AnyChatMessage]] = [] + + @overload + async def chat_completions( + self, param: ChatCompletionsParam, *, stream: Literal[False] = False + ) -> ChatCompletionResponse: ... + + @overload + async def chat_completions( + self, param: ChatCompletionsParam, *, stream: Literal[True] + ) -> AsyncIterator[ChatCompletionChunk]: ... + + async def chat_completions( + self, param: ChatCompletionsParam, *, stream: bool = False + ) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]: + del stream + self.calls += 1 + self.payloads.append(list(param.messages)) + if self.calls == 1: + # First stop → end-of-turn synthetic nag → second call clears. + message = AssistantChatMessage(content="first stop") + finish = "stop" + elif self.calls == 2: + message = AssistantChatMessage( + content="", + tool_calls=[ + AssistantFunctionToolCall( + id="clr", + function=AssistantFunctionTool(name="todo_clear", arguments="{}"), + ) + ], + ) + finish = "tool_calls" + else: + message = AssistantChatMessage(content="after clear") + finish = "stop" + return ChatCompletionResponse( + id="1", + object="chat.completion", + created=0, + model="m", + choices=[ + ChatCompletionChoice( + index=0, + message=message, + logprobs={}, + finish_reason=finish, + ) + ], + system_fingerprint="", + usage={}, + ) + + stack = TodoStack() + _ = stack.push("was dirty") + client = CaptureClient() + agent = ChatAgent( + client, # type: ignore[arg-type] + model="m", + tools=ToolRegistry(list(TODO_TOOLS)), + stream=False, + todo_stack=stack, + todo_nag_strategy="synthetic_tool", + ) + set_todo_stack(stack) + try: + async for _event in agent.run("turn1"): + pass + assert stack.is_empty() + n_after_turn1 = client.calls + async for _event in agent.run("turn2 clean"): + pass + # Later request payloads must not re-present the old dirty item via synth nags. + for payload in client.payloads[n_after_turn1:]: + for msg in payload: + if isinstance(msg, ToolChatMessage) and msg.tool_call_id.startswith("todo-nag-"): + assert "was dirty" not in msg.content + # Durable history refreshed at turn start as well. + for msg in agent.messages: + if isinstance(msg, ToolChatMessage) and msg.tool_call_id.startswith("todo-nag-"): + assert "was dirty" not in msg.content + finally: + set_todo_stack(None)