core/agent: configurable todo nag strategies

Add [agent] todo_nag_strategy: developer (default), system, user,
synthetic_tool (forged todo_list pair), or none. Shared inject helper
used for turn-start and end-of-turn nags; CLI wires agent config.
This commit is contained in:
2026-07-20 03:33:02 +08:00
parent a4ab11c9a2
commit f6d14ed549
8 changed files with 235 additions and 10 deletions
+12 -2
View File
@@ -7,7 +7,6 @@ from msgspec import UNSET
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
AssistantFunctionToolCall,
DeveloperChatMessage,
SystemChatMessage,
ToolChatMessage,
UserChatMessage,
@@ -20,6 +19,7 @@ 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 .usage import TokenUsage
if TYPE_CHECKING:
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
from .client import ChatClient
from .events import AgentEvent
from .todo_nag import TodoNagStrategy
from .todo_stack import TodoStack
from .tools import ToolRegistry
@@ -121,6 +122,7 @@ class ChatAgent:
parallel_tools: bool
max_context_tokens: int
todo_stack: TodoStack | None
todo_nag_strategy: TodoNagStrategy
messages: list[AnyChatMessage]
session_usage: TokenUsage
last_turn_usage: TokenUsage
@@ -147,6 +149,7 @@ class ChatAgent:
parallel_tools: bool = True,
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
todo_stack: TodoStack | None = None,
todo_nag_strategy: str | TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
) -> None:
self.client = client
self.model = model
@@ -162,6 +165,7 @@ class ChatAgent:
self.parallel_tools = parallel_tools
self.max_context_tokens = max_context_tokens
self.todo_stack = todo_stack
self.todo_nag_strategy = parse_todo_nag_strategy(str(todo_nag_strategy))
self.messages = list(messages) if messages is not None else []
self.session_usage = TokenUsage()
self.last_turn_usage = TokenUsage()
@@ -298,7 +302,12 @@ class ChatAgent:
self.todo_stack.begin_turn()
# Non-empty stack at turn start → remind the model before first completion.
if not self.todo_stack.is_empty():
self.messages.append(DeveloperChatMessage(content=self.todo_stack.turn_reminder_prompt()))
_ = inject_todo_nag_for_stack(
self.messages,
self.todo_stack,
kind="turn_start",
strategy=self.todo_nag_strategy,
)
completed = False
turn_usage = TokenUsage()
@@ -318,6 +327,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,
):
if isinstance(event, UsageEvent):
turn_rounds += 1
+14 -7
View File
@@ -13,7 +13,6 @@ from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
AssistantFunctionToolCall,
ChatCompletionsParam,
DeveloperChatMessage,
StreamOptions,
StreamToolCallDelta,
ToolChatMessage,
@@ -38,6 +37,7 @@ from .events import (
ToolResultEvent,
UsageEvent,
)
from .todo_nag import DEFAULT_TODO_NAG_STRATEGY, inject_todo_nag_for_stack
from .usage import resolve_round_usage, token_usage_from_api
if TYPE_CHECKING:
@@ -46,6 +46,7 @@ if TYPE_CHECKING:
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
from .client import ChatClient
from .todo_nag import TodoNagStrategy
from .todo_stack import TodoStack
from .tools import ToolRegistry
@@ -307,7 +308,7 @@ def _last_assistant(messages: list[AnyChatMessage], pre_len: int) -> AssistantCh
return last
async def run_chat_loop(
async def run_chat_loop( # noqa: C901 — multi-phase tool loop
client: ChatClient,
messages: list[AnyChatMessage],
*,
@@ -321,6 +322,7 @@ async def run_chat_loop(
parallel_tools: bool = True,
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
todo_stack: TodoStack | None = None,
todo_nag_strategy: TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
) -> AsyncIterator[AgentEvent]:
"""Multi-round chat/tool loop; mutates ``messages`` in place and yields events.
@@ -330,8 +332,8 @@ async def run_chat_loop(
Request payloads may shrink older tool results when over ``max_context_tokens``.
When *todo_stack* is set and still needs review after a natural stop
(open items, or non-empty stack untouched this turn), injects a developer
review message and continues once so the model reconciles unfinished work.
(open items, or non-empty stack untouched this turn), injects a review nag
(channel from *todo_nag_strategy*) once so the model reconciles unfinished work.
"""
tool_items: Sequence[AnyToolItem] | None = None
if tools is not None and len(tools) > 0:
@@ -373,9 +375,14 @@ async def run_chat_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
# Non-user control identity so retry/history don't treat this as human input.
messages.append(DeveloperChatMessage(content=todo_stack.review_prompt()))
continue
injected = inject_todo_nag_for_stack(
messages,
todo_stack,
kind="end_of_turn",
strategy=todo_nag_strategy,
)
if injected:
continue
return
async for event in _execute_tool_calls(
tools,
+106
View File
@@ -0,0 +1,106 @@
"""Inject todo stack nags into the message list (configurable channel)."""
from __future__ import annotations
import uuid
from typing import TYPE_CHECKING, Literal, cast
from msgspec import UNSET
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
AssistantFunctionTool,
AssistantFunctionToolCall,
DeveloperChatMessage,
SystemChatMessage,
ToolChatMessage,
UserChatMessage,
)
if TYPE_CHECKING:
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
from .todo_stack import TodoStack
type TodoNagStrategy = Literal["developer", "system", "user", "synthetic_tool", "none"]
type TodoNagKind = Literal["turn_start", "end_of_turn"]
TODO_NAG_STRATEGIES: frozenset[str] = frozenset({"developer", "system", "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."""
token = (raw or DEFAULT_TODO_NAG_STRATEGY).strip().lower().replace("-", "_")
if token not in TODO_NAG_STRATEGIES:
return DEFAULT_TODO_NAG_STRATEGY
return cast("TodoNagStrategy", token)
def nag_body(stack: TodoStack, kind: TodoNagKind) -> str:
if kind == "turn_start":
return stack.turn_reminder_prompt()
return stack.review_prompt()
def _append_synthetic_todo_list(messages: list[AnyChatMessage], body: str) -> None:
call_id = f"todo-nag-{uuid.uuid4().hex[:12]}"
messages.append(
AssistantChatMessage(
content=UNSET,
tool_calls=[
AssistantFunctionToolCall(
id=call_id,
function=AssistantFunctionTool(
name=_SYNTHETIC_TOOL_NAME,
arguments="{}",
),
)
],
)
)
messages.append(ToolChatMessage(tool_call_id=call_id, content=body))
def inject_todo_nag(
messages: list[AnyChatMessage],
body: str,
*,
strategy: TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
) -> bool:
"""Append a todo nag using *strategy*. Returns True if anything was appended.
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
def inject_todo_nag_for_stack(
messages: list[AnyChatMessage],
stack: TodoStack,
*,
kind: TodoNagKind,
strategy: TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
) -> bool:
"""Build body from *stack* and inject with *strategy*."""
return inject_todo_nag(messages, nag_body(stack, kind), strategy=strategy)
+1
View File
@@ -187,6 +187,7 @@ class ReplState:
parallel_tools=agent_cfg.parallel_tools,
max_context_tokens=agent_cfg.max_context_tokens,
todo_stack=self.todo_stack,
todo_nag_strategy=agent_cfg.todo_nag_strategy,
)
def rebuild_client(self) -> None:
+4
View File
@@ -27,6 +27,10 @@ class AgentConfig(Struct, omit_defaults=True):
path_denylist: list[str] = field(default_factory=list)
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
todo_nag_strategy: str = "developer"
# Compact / summarisation prompts (empty = use built-in defaults).
compact_system_prompt: str = ""
compact_user_prefix: str = ""