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,
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()
+9 -1
View File
@@ -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,
+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"})
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,