diff --git a/src/plyngent/agent/chat.py b/src/plyngent/agent/chat.py index 25f115a..2d09594 100644 --- a/src/plyngent/agent/chat.py +++ b/src/plyngent/agent/chat.py @@ -26,6 +26,7 @@ from .loop import DEFAULT_MAX_ROUNDS, run_chat_loop from .todo_nag import ( DEFAULT_TODO_NAG_STRATEGY, inject_todo_nag_for_stack_with_events, + is_synthetic_todo_nag_call_id, parse_todo_nag_strategy, refresh_synthetic_todo_nags, ) @@ -114,6 +115,37 @@ def committed_prefix_end(messages: Sequence[AnyChatMessage], user_index: int) -> 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: """Thin wrapper: chat client + optional tools + optional memory bind.""" @@ -398,14 +430,25 @@ class ChatAgent: turn_rounds = 0 last_request = TokenUsage() try: + _turn_nag_strategy: TodoNagStrategy = self.todo_nag_strategy # Turn-start nag before first completion; yield events so CLI flushes # (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( self.messages, self.todo_stack, kind="turn_start", - strategy=self.todo_nag_strategy, + strategy=_turn_nag_strategy, ) for nag_event in nag_events: yield nag_event @@ -423,7 +466,7 @@ class ChatAgent: parallel_tools=self.parallel_tools, max_context_tokens=self.max_context_tokens, 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_text=self.directive_reminder_text, reminder_last_band=self.reminder_last_band,