core/agent: skip duplicate synthetic todo nag injection on retry

This commit is contained in:
2026-07-28 22:44:21 +08:00
parent 9600d2f757
commit fc98b080e1
+46 -3
View File
@@ -26,6 +26,7 @@ from .loop import DEFAULT_MAX_ROUNDS, run_chat_loop
from .todo_nag import ( 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,
is_synthetic_todo_nag_call_id,
parse_todo_nag_strategy, parse_todo_nag_strategy,
refresh_synthetic_todo_nags, refresh_synthetic_todo_nags,
) )
@@ -114,6 +115,37 @@ def committed_prefix_end(messages: Sequence[AnyChatMessage], user_index: int) ->
return end return end
def _synthetic_todo_pair_after(
messages: Sequence[AnyChatMessage],
index: int,
) -> bool:
"""True when a synthetic todo nag pair sits at ``messages[index+1:index+3]``.
The pattern is ``AssistantChatMessage`` (all tool calls synthetic) followed
by a matching ``ToolChatMessage`` with a synthetic call id.
On retry after a failed turn, a previously injected turn-start nag may still
be in messages (survived rollback). This detects it so we can skip a fresh
injection.
"""
if index + 2 >= len(messages):
return False
first = messages[index + 1]
if not isinstance(first, AssistantChatMessage):
return False
tool_calls = first.tool_calls
if tool_calls is UNSET or not tool_calls:
return False
if not all(
isinstance(call, AssistantFunctionToolCall) and is_synthetic_todo_nag_call_id(call.id) for call in tool_calls
):
return False
synth_ids = {call.id for call in tool_calls if isinstance(call, AssistantFunctionToolCall)}
second = messages[index + 2]
if not isinstance(second, ToolChatMessage):
return False
return second.tool_call_id in synth_ids
class ChatAgent: class ChatAgent:
"""Thin wrapper: chat client + optional tools + optional memory bind.""" """Thin wrapper: chat client + optional tools + optional memory bind."""
@@ -398,14 +430,25 @@ class ChatAgent:
turn_rounds = 0 turn_rounds = 0
last_request = TokenUsage() last_request = TokenUsage()
try: try:
_turn_nag_strategy: TodoNagStrategy = self.todo_nag_strategy
# Turn-start nag before first completion; yield events so CLI flushes # Turn-start nag before first completion; yield events so CLI flushes
# (synthetic_tool → ToolCall/Result chrome, not glued to text). # (synthetic_tool → ToolCall/Result chrome, not glued to text).
if self.todo_stack is not None and not self.todo_stack.is_empty(): # On retry, a synthetic_tool pair from a previous failed attempt may
# have survived rollback (it looks like a committed tool batch).
# Skip fresh injection — refresh_synthetic_todo_nags already updated
# the content of the surviving pair above.
if (
self.todo_stack is not None
and not self.todo_stack.is_empty()
and (
_turn_nag_strategy != "synthetic_tool" or not _synthetic_todo_pair_after(self.messages, user_index)
)
):
_injected, nag_events = inject_todo_nag_for_stack_with_events( _injected, nag_events = inject_todo_nag_for_stack_with_events(
self.messages, self.messages,
self.todo_stack, self.todo_stack,
kind="turn_start", kind="turn_start",
strategy=self.todo_nag_strategy, strategy=_turn_nag_strategy,
) )
for nag_event in nag_events: for nag_event in nag_events:
yield nag_event yield nag_event
@@ -423,7 +466,7 @@ class ChatAgent:
parallel_tools=self.parallel_tools, parallel_tools=self.parallel_tools,
max_context_tokens=self.max_context_tokens, max_context_tokens=self.max_context_tokens,
todo_stack=self.todo_stack, todo_stack=self.todo_stack,
todo_nag_strategy=self.todo_nag_strategy, todo_nag_strategy=_turn_nag_strategy,
directive_reminder_tokens=self.directive_reminder_tokens, directive_reminder_tokens=self.directive_reminder_tokens,
directive_reminder_text=self.directive_reminder_text, directive_reminder_text=self.directive_reminder_text,
reminder_last_band=self.reminder_last_band, reminder_last_band=self.reminder_last_band,