diff --git a/CLAUDE.md b/CLAUDE.md index c5ca9f0..48fb929 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`) diff --git a/doc/plyngent.example.toml b/doc/plyngent.example.toml index 79f9eb0..1d50199 100644 --- a/doc/plyngent.example.toml +++ b/doc/plyngent.example.toml @@ -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). diff --git a/src/plyngent/agent/chat.py b/src/plyngent/agent/chat.py index 0663633..68cbb90 100644 --- a/src/plyngent/agent/chat.py +++ b/src/plyngent/agent/chat.py @@ -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, diff --git a/src/plyngent/agent/loop.py b/src/plyngent/agent/loop.py index 4144030..ceb735b 100644 --- a/src/plyngent/agent/loop.py +++ b/src/plyngent/agent/loop.py @@ -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 diff --git a/src/plyngent/agent/todo_nag.py b/src/plyngent/agent/todo_nag.py index baed018..472a621 100644 --- a/src/plyngent/agent/todo_nag.py +++ b/src/plyngent/agent/todo_nag.py @@ -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 diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index 3b7db13..71de8b3 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -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). diff --git a/tests/test_agent/test_todo_stack.py b/tests/test_agent/test_todo_stack.py index a97bb97..9c40780 100644 --- a/tests/test_agent/test_todo_stack.py +++ b/tests/test_agent/test_todo_stack.py @@ -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: