core/agent: refresh synthetic_tool todo nags to live stack

This commit is contained in:
2026-07-21 22:42:27 +08:00
parent 6d74d31cea
commit b51d4780ab
4 changed files with 185 additions and 2 deletions
+4
View File
@@ -23,6 +23,7 @@ from .todo_nag import (
DEFAULT_TODO_NAG_STRATEGY, DEFAULT_TODO_NAG_STRATEGY,
inject_todo_nag_for_stack_with_events, inject_todo_nag_for_stack_with_events,
parse_todo_nag_strategy, parse_todo_nag_strategy,
refresh_synthetic_todo_nags,
) )
from .usage import TokenUsage from .usage import TokenUsage
@@ -304,6 +305,9 @@ class ChatAgent:
user_index = self._user_index(user_msg) user_index = self._user_index(user_msg)
if self.todo_stack is not None: if self.todo_stack is not None:
self.todo_stack.begin_turn() 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 completed = False
turn_usage = TokenUsage() turn_usage = TokenUsage()
+9 -1
View File
@@ -37,7 +37,11 @@ from .events import (
ToolResultEvent, ToolResultEvent,
UsageEvent, 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 from .usage import resolve_round_usage, token_usage_from_api
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -349,12 +353,16 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
while True: while True:
while rounds_used < allowance: while rounds_used < allowance:
rounds_used += 1 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( request_messages = compact_messages_for_request(
messages, messages,
max_tokens=max_context_tokens, max_tokens=max_context_tokens,
prompt_tokens_hint=prompt_tokens_hint, prompt_tokens_hint=prompt_tokens_hint,
sent_estimate_tokens=sent_estimate_tokens, 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) sent_est = estimate_messages_tokens(request_messages)
param = ChatCompletionsParam( param = ChatCompletionsParam(
messages=request_messages, messages=request_messages,
+52 -1
View File
@@ -29,6 +29,8 @@ type TodoNagKind = Literal["turn_start", "end_of_turn"]
TODO_NAG_STRATEGIES: frozenset[str] = frozenset({"developer", "user", "synthetic_tool", "none"}) TODO_NAG_STRATEGIES: frozenset[str] = frozenset({"developer", "user", "synthetic_tool", "none"})
DEFAULT_TODO_NAG_STRATEGY: TodoNagStrategy = "developer" DEFAULT_TODO_NAG_STRATEGY: TodoNagStrategy = "developer"
_SYNTHETIC_TOOL_NAME = "todo_list" _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: 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: def _append_synthetic_todo_list(messages: list[AnyChatMessage], body: str) -> str:
"""Append forged todo_list call + result. Returns the synthetic tool_call id.""" """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( messages.append(
AssistantChatMessage( AssistantChatMessage(
content=UNSET, content=UNSET,
@@ -82,6 +84,55 @@ def _append_synthetic_todo_list(messages: list[AnyChatMessage], body: str) -> st
return call_id 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( def inject_todo_nag(
messages: list[AnyChatMessage], messages: list[AnyChatMessage],
body: str, body: str,
+120
View File
@@ -497,3 +497,123 @@ async def test_loop_none_nag_strategy_skips_inject() -> None:
) )
finally: finally:
set_todo_stack(None) 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)