diff --git a/CLAUDE.md b/CLAUDE.md index 48fb929..b5cf04a 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 | `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`) diff --git a/src/plyngent/agent/todo_nag.py b/src/plyngent/agent/todo_nag.py index 472a621..afe79c7 100644 --- a/src/plyngent/agent/todo_nag.py +++ b/src/plyngent/agent/todo_nag.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 diff --git a/tests/test_agent/test_todo_stack.py b/tests/test_agent/test_todo_stack.py index 9c40780..ac444dc 100644 --- a/tests/test_agent/test_todo_stack.py +++ b/tests/test_agent/test_todo_stack.py @@ -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)