mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-21 21:14:58 +08:00
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:
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user