core/agent: drop system todo nag; emit events for synthetic_tool

Remove system strategy (legacy maps to developer). synthetic_tool still
forges the call but uses real stack text as the tool result, and now
yields ToolCall/Result events so the CLI flushes and does not glue nag
text to the next assistant stream.
This commit is contained in:
2026-07-20 03:55:50 +08:00
parent f6d14ed549
commit 2e6cb3834a
7 changed files with 115 additions and 41 deletions
+17 -9
View File
@@ -19,7 +19,11 @@ from .budget import (
)
from .events import UsageEvent
from .loop import DEFAULT_MAX_ROUNDS, run_chat_loop
from .todo_nag import DEFAULT_TODO_NAG_STRATEGY, inject_todo_nag_for_stack, parse_todo_nag_strategy
from .todo_nag import (
DEFAULT_TODO_NAG_STRATEGY,
inject_todo_nag_for_stack_with_events,
parse_todo_nag_strategy,
)
from .usage import TokenUsage
if TYPE_CHECKING:
@@ -300,20 +304,24 @@ class ChatAgent:
user_index = self._user_index(user_msg)
if self.todo_stack is not None:
self.todo_stack.begin_turn()
# Non-empty stack at turn start → remind the model before first completion.
if not self.todo_stack.is_empty():
_ = inject_todo_nag_for_stack(
self.messages,
self.todo_stack,
kind="turn_start",
strategy=self.todo_nag_strategy,
)
completed = False
turn_usage = TokenUsage()
turn_rounds = 0
last_request = TokenUsage()
try:
# 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():
_injected, nag_events = inject_todo_nag_for_stack_with_events(
self.messages,
self.todo_stack,
kind="turn_start",
strategy=self.todo_nag_strategy,
)
for nag_event in nag_events:
yield nag_event
async for event in run_chat_loop(
self.client,
self.messages,
+4 -2
View File
@@ -37,7 +37,7 @@ from .events import (
ToolResultEvent,
UsageEvent,
)
from .todo_nag import DEFAULT_TODO_NAG_STRATEGY, inject_todo_nag_for_stack
from .todo_nag import DEFAULT_TODO_NAG_STRATEGY, inject_todo_nag_for_stack_with_events
from .usage import resolve_round_usage, token_usage_from_api
if TYPE_CHECKING:
@@ -375,12 +375,14 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
if tool_calls is UNSET or not tool_calls or tools is None:
if todo_stack is not None and todo_stack.needs_review() and not todo_review_injected:
todo_review_injected = True
injected = inject_todo_nag_for_stack(
injected, nag_events = inject_todo_nag_for_stack_with_events(
messages,
todo_stack,
kind="end_of_turn",
strategy=todo_nag_strategy,
)
for nag_event in nag_events:
yield nag_event
if injected:
continue
return
+79 -21
View File
@@ -12,27 +12,35 @@ from plyngent.lmproto.openai_compatible.model import (
AssistantFunctionTool,
AssistantFunctionToolCall,
DeveloperChatMessage,
SystemChatMessage,
ToolChatMessage,
UserChatMessage,
)
if TYPE_CHECKING:
from collections.abc import Iterator
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
from .events import AgentEvent
from .todo_stack import TodoStack
type TodoNagStrategy = Literal["developer", "system", "user", "synthetic_tool", "none"]
type TodoNagStrategy = Literal["developer", "user", "synthetic_tool", "none"]
type TodoNagKind = Literal["turn_start", "end_of_turn"]
TODO_NAG_STRATEGIES: frozenset[str] = frozenset({"developer", "system", "user", "synthetic_tool", "none"})
TODO_NAG_STRATEGIES: frozenset[str] = frozenset({"developer", "user", "synthetic_tool", "none"})
DEFAULT_TODO_NAG_STRATEGY: TodoNagStrategy = "developer"
_SYNTHETIC_TOOL_NAME = "todo_list"
def parse_todo_nag_strategy(raw: str | None) -> TodoNagStrategy:
"""Normalize config/CLI text to a strategy; unknown → developer."""
"""Normalize config/CLI text to a strategy; unknown → developer.
Legacy ``system`` is accepted as ``developer`` (mid-turn system was folded
into Responses ``instructions`` and was not a useful distinct channel).
"""
token = (raw or DEFAULT_TODO_NAG_STRATEGY).strip().lower().replace("-", "_")
if token == "system":
return "developer"
if token not in TODO_NAG_STRATEGIES:
return DEFAULT_TODO_NAG_STRATEGY
return cast("TodoNagStrategy", token)
@@ -44,7 +52,8 @@ def nag_body(stack: TodoStack, kind: TodoNagKind) -> str:
return stack.review_prompt()
def _append_synthetic_todo_list(messages: list[AnyChatMessage], body: str) -> None:
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]}"
messages.append(
AssistantChatMessage(
@@ -61,6 +70,7 @@ def _append_synthetic_todo_list(messages: list[AnyChatMessage], body: str) -> No
)
)
messages.append(ToolChatMessage(tool_call_id=call_id, content=body))
return call_id
def inject_todo_nag(
@@ -73,26 +83,54 @@ def inject_todo_nag(
Strategies:
- ``developer`` (default): control-plane message (safe for retry/history).
- ``system``: mid-conversation system message.
- ``user``: looks like a human turn (can confuse retry — use with care).
- ``synthetic_tool``: forged ``todo_list`` call + result pair (no handler run).
- ``none``: no injection.
"""
if strategy == "none" or not body.strip():
return False
envelopes: dict[TodoNagStrategy, type] = {
"developer": DeveloperChatMessage,
"system": SystemChatMessage,
"user": UserChatMessage,
}
if strategy in envelopes:
messages.append(envelopes[strategy](content=body))
return True
if strategy == "synthetic_tool":
_append_synthetic_todo_list(messages, body)
return True
return False
Prefer :func:`inject_todo_nag_with_events` when the CLI needs ToolCall/Result
events so the display buffer flushes and tool chrome is not glued to text.
"""
return inject_todo_nag_with_events(messages, body, strategy=strategy)[0]
def inject_todo_nag_with_events(
messages: list[AnyChatMessage],
body: str,
*,
strategy: TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
) -> tuple[bool, list[AgentEvent]]:
"""Like :func:`inject_todo_nag`, also return display events for synthetic_tool.
For ``synthetic_tool``, emits :class:`ToolCallEvent` then
:class:`ToolResultEvent` so streaming UIs flush the assistant buffer and
show tool chrome (result is real stack text; call was not model-authored).
"""
from .events import ToolCallEvent, ToolResultEvent
if strategy == "none" or not body.strip():
return False, []
if strategy == "developer":
messages.append(DeveloperChatMessage(content=body))
return True, []
if strategy == "user":
messages.append(UserChatMessage(content=body))
return True, []
# strategy == "synthetic_tool" (only remaining inject path)
call_id = _append_synthetic_todo_list(messages, body)
assistant = messages[-2]
tool_msg = messages[-1]
events: list[AgentEvent] = []
if isinstance(assistant, AssistantChatMessage):
tool_calls = assistant.tool_calls
if tool_calls is not UNSET and tool_calls:
for call in tool_calls:
if getattr(call, "id", None) == call_id:
events.append(ToolCallEvent(tool_call=call))
break
if isinstance(tool_msg, ToolChatMessage):
events.append(ToolResultEvent(message=tool_msg))
return True, events
def inject_todo_nag_for_stack(
@@ -104,3 +142,23 @@ def inject_todo_nag_for_stack(
) -> bool:
"""Build body from *stack* and inject with *strategy*."""
return inject_todo_nag(messages, nag_body(stack, kind), strategy=strategy)
def inject_todo_nag_for_stack_with_events(
messages: list[AnyChatMessage],
stack: TodoStack,
*,
kind: TodoNagKind,
strategy: TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
) -> tuple[bool, list[AgentEvent]]:
"""Build body from *stack*; inject and return CLI-facing events."""
return inject_todo_nag_with_events(
messages,
nag_body(stack, kind),
strategy=strategy,
)
def iter_synthetic_nag_events(events: list[AgentEvent]) -> Iterator[AgentEvent]:
"""Yield events for the agent loop (empty if strategy was non-synthetic)."""
yield from events
+1 -1
View File
@@ -28,7 +28,7 @@ class AgentConfig(Struct, omit_defaults=True):
max_context_tokens: int = 200_000
# How to inject todo stack nags into model context (see agent/todo_nag.py).
# developer | system | user | synthetic_tool | none
# developer | user | synthetic_tool | none (legacy "system" → developer)
todo_nag_strategy: str = "developer"
# Compact / summarisation prompts (empty = use built-in defaults).