core/agent: synthetic_tool nag uses real todo_list render body

developer/user keep OPEN WORK prose; synthetic_tool injects forged
todo_list call with stack.render() only so the result matches a real
tool output.
This commit is contained in:
2026-07-20 04:10:21 +08:00
parent 2e6cb3834a
commit 59017fd61b
3 changed files with 73 additions and 17 deletions
+1 -1
View File
@@ -86,7 +86,7 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for shells/REPLs and `python|bash -c` one-liners (argv + code preview); deny may include a user comment for the model. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn).
- **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems.
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core).
- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by id; DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work (`[TODO OPEN WORK]`); all-terminal non-empty = hygiene (`[TODO HYGIENE]`); nag channel via `[agent] todo_nag_strategy` (`developer` default | `user` | `synthetic_tool` | `none`; synthetic emits ToolCall/Result events so CLI flushes; call is forged, result body is real stack text).
- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by id; DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work (`[TODO OPEN WORK]`); all-terminal non-empty = hygiene (`[TODO HYGIENE]`); nag channel via `[agent] todo_nag_strategy` (`developer`/`user` = prose nag; `synthetic_tool` = forged `todo_list` call + **real** `stack.render()` result + ToolCall/Result events; `none`).
- **`DEFAULT_TOOLS`**: file + process + vcs + chat + todo tool list for a `ToolRegistry`.
### Prompting (`prompting.py`)
+30 -11
View File
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Literal, cast
from msgspec import UNSET
from plyngent.lmproto.openai_compatible.model import (
AnyAssistantToolCall,
AssistantChatMessage,
AssistantFunctionTool,
AssistantFunctionToolCall,
@@ -17,8 +18,6 @@ from plyngent.lmproto.openai_compatible.model import (
)
if TYPE_CHECKING:
from collections.abc import Iterator
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
from .events import AgentEvent
@@ -47,11 +46,21 @@ def parse_todo_nag_strategy(raw: str | None) -> TodoNagStrategy:
def nag_body(stack: TodoStack, kind: TodoNagKind) -> str:
"""Prose OPEN WORK / HYGIENE prompt (developer / user strategies)."""
if kind == "turn_start":
return stack.turn_reminder_prompt()
return stack.review_prompt()
def synthetic_todo_list_result(stack: TodoStack) -> str:
"""Body for synthetic_tool: same text as a real ``todo_list`` tool result.
No OPEN WORK lecture — just the stack dump the model would get from
``todo_list``. The call remains forged; the payload is authentic render.
"""
return stack.render()
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]}"
@@ -125,14 +134,25 @@ def inject_todo_nag_with_events(
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))
if isinstance(call, AssistantFunctionToolCall) and call.id == call_id:
events.append(ToolCallEvent(tool_call=cast("AnyAssistantToolCall", call)))
break
if isinstance(tool_msg, ToolChatMessage):
events.append(ToolResultEvent(message=tool_msg))
return True, events
def body_for_strategy(
stack: TodoStack,
kind: TodoNagKind,
strategy: TodoNagStrategy,
) -> str:
"""Choose inject payload: prose nag vs real todo_list render."""
if strategy == "synthetic_tool":
return synthetic_todo_list_result(stack)
return nag_body(stack, kind)
def inject_todo_nag_for_stack(
messages: list[AnyChatMessage],
stack: TodoStack,
@@ -141,7 +161,11 @@ def inject_todo_nag_for_stack(
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)
return inject_todo_nag(
messages,
body_for_strategy(stack, kind, strategy),
strategy=strategy,
)
def inject_todo_nag_for_stack_with_events(
@@ -154,11 +178,6 @@ def inject_todo_nag_for_stack_with_events(
"""Build body from *stack*; inject and return CLI-facing events."""
return inject_todo_nag_with_events(
messages,
nag_body(stack, kind),
body_for_strategy(stack, kind, strategy),
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
+42 -5
View File
@@ -132,7 +132,11 @@ def test_parse_todo_nag_strategy() -> None:
def test_inject_todo_nag_strategies() -> None:
from plyngent.agent.events import ToolCallEvent, ToolResultEvent
from plyngent.agent.todo_nag import inject_todo_nag_with_events
from plyngent.agent.todo_nag import (
inject_todo_nag_for_stack_with_events,
inject_todo_nag_with_events,
synthetic_todo_list_result,
)
body = "[TODO OPEN WORK] test"
messages: list[AnyChatMessage] = []
@@ -148,20 +152,49 @@ def test_inject_todo_nag_strategies() -> None:
assert inject_todo_nag(messages, body, strategy="user") is True
assert isinstance(messages[0], UserChatMessage)
# synthetic_tool: real todo_list-shaped body (stack.render), not OPEN WORK prose
stack = TodoStack()
_ = stack.push("synthetic body item")
real = synthetic_todo_list_result(stack)
assert real == stack.render()
assert "[TODO OPEN WORK]" not in real
assert "synthetic body item" in real
messages = []
ok, events = inject_todo_nag_with_events(messages, body, strategy="synthetic_tool")
ok, events = inject_todo_nag_for_stack_with_events(
messages,
stack,
kind="end_of_turn",
strategy="synthetic_tool",
)
assert ok is True
assert len(messages) == 2
assert isinstance(messages[0], AssistantChatMessage)
tool_calls = messages[0].tool_calls
assert tool_calls is not UNSET and tool_calls
assert isinstance(messages[1], ToolChatMessage)
assert body in messages[1].content
tool_content = messages[1].content
assert isinstance(tool_content, str)
assert tool_content == real
assert messages[1].tool_call_id.startswith("todo-nag-")
assert len(events) == 2
assert isinstance(events[0], ToolCallEvent)
assert isinstance(events[1], ToolResultEvent)
assert body in events[1].message.content
result_event = events[1]
assert isinstance(result_event, ToolResultEvent)
result_content = result_event.message.content
assert isinstance(result_content, str)
assert result_content == real
# Raw inject still accepts an explicit body (e.g. tests / custom)
messages = []
ok2, events2 = inject_todo_nag_with_events(messages, body, strategy="synthetic_tool")
assert ok2
assert len(events2) == 2
assert isinstance(events2[1], ToolResultEvent)
raw_content = events2[1].message.content
assert isinstance(raw_content, str)
assert body in raw_content
def test_todo_prompts_signal_undone_work() -> None:
@@ -360,7 +393,11 @@ async def test_loop_synthetic_tool_nag_strategy() -> None:
try:
async for _event in agent.run("do stuff"):
pass
assert any(isinstance(m, ToolChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages)
# Result body is real todo_list shape (render), not OPEN WORK prose.
assert any(
isinstance(m, ToolChatMessage) and "open work" in m.content and "[TODO OPEN WORK]" not in m.content
for m in agent.messages
)
assert not any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages)
finally:
set_todo_stack(None)