mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-21 21:14:58 +08:00
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:
@@ -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 | `system` | `user` | `synthetic_tool` | `none`).
|
||||
- **`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).
|
||||
- **`DEFAULT_TOOLS`**: file + process + vcs + chat + todo tool list for a `ToolRegistry`.
|
||||
|
||||
### Prompting (`prompting.py`)
|
||||
|
||||
@@ -19,7 +19,8 @@ path_denylist = ["/secrets/", ".ssh/", ".gnupg/"]
|
||||
# Soft context budget in estimated tokens (~4 chars/token when API usage missing).
|
||||
max_context_tokens = 200000
|
||||
# How open-todo nags are injected into model context:
|
||||
# developer (default) | system | user | synthetic_tool | none
|
||||
# developer (default) | user | synthetic_tool | none
|
||||
# (legacy system is treated as developer)
|
||||
# todo_nag_strategy = "developer"
|
||||
|
||||
# Optional: custom compact/summarisation prompts (empty = built-in defaults).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -17,7 +17,6 @@ from plyngent.lmproto.openai_compatible.model import (
|
||||
ChatCompletionResponse,
|
||||
ChatCompletionsParam,
|
||||
DeveloperChatMessage,
|
||||
SystemChatMessage,
|
||||
ToolChatMessage,
|
||||
UserChatMessage,
|
||||
)
|
||||
@@ -127,9 +126,14 @@ def test_parse_todo_nag_strategy() -> None:
|
||||
assert parse_todo_nag_strategy("SYNTHETIC-TOOL") == "synthetic_tool"
|
||||
assert parse_todo_nag_strategy("nope") == "developer"
|
||||
assert parse_todo_nag_strategy(None) == "developer"
|
||||
# Legacy alias — mid-turn system was not a useful Responses channel.
|
||||
assert parse_todo_nag_strategy("system") == "developer"
|
||||
|
||||
|
||||
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
|
||||
|
||||
body = "[TODO OPEN WORK] test"
|
||||
messages: list[AnyChatMessage] = []
|
||||
assert inject_todo_nag(messages, body, strategy="none") is False
|
||||
@@ -140,16 +144,13 @@ def test_inject_todo_nag_strategies() -> None:
|
||||
assert isinstance(messages[0], DeveloperChatMessage)
|
||||
assert body in messages[0].content
|
||||
|
||||
messages = []
|
||||
assert inject_todo_nag(messages, body, strategy="system") is True
|
||||
assert isinstance(messages[0], SystemChatMessage)
|
||||
|
||||
messages = []
|
||||
assert inject_todo_nag(messages, body, strategy="user") is True
|
||||
assert isinstance(messages[0], UserChatMessage)
|
||||
|
||||
messages = []
|
||||
assert inject_todo_nag(messages, body, strategy="synthetic_tool") is True
|
||||
ok, events = inject_todo_nag_with_events(messages, body, strategy="synthetic_tool")
|
||||
assert ok is True
|
||||
assert len(messages) == 2
|
||||
assert isinstance(messages[0], AssistantChatMessage)
|
||||
tool_calls = messages[0].tool_calls
|
||||
@@ -157,6 +158,10 @@ def test_inject_todo_nag_strategies() -> None:
|
||||
assert isinstance(messages[1], ToolChatMessage)
|
||||
assert body in messages[1].content
|
||||
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
|
||||
|
||||
|
||||
def test_todo_prompts_signal_undone_work() -> None:
|
||||
|
||||
Reference in New Issue
Block a user