diff --git a/CLAUDE.md b/CLAUDE.md index 72a93b1..c5ca9f0 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]` turn-start + end-of-turn review); all-terminal non-empty = hygiene (`[TODO HYGIENE]`). +- **`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`). - **`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 13470d4..79f9eb0 100644 --- a/doc/plyngent.example.toml +++ b/doc/plyngent.example.toml @@ -18,6 +18,9 @@ confirm_destructive = true 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 +# todo_nag_strategy = "developer" # Optional: custom compact/summarisation prompts (empty = built-in defaults). # compact_system_prompt = "" diff --git a/src/plyngent/agent/chat.py b/src/plyngent/agent/chat.py index b9c577f..0663633 100644 --- a/src/plyngent/agent/chat.py +++ b/src/plyngent/agent/chat.py @@ -7,7 +7,6 @@ from msgspec import UNSET from plyngent.lmproto.openai_compatible.model import ( AssistantChatMessage, AssistantFunctionToolCall, - DeveloperChatMessage, SystemChatMessage, ToolChatMessage, UserChatMessage, @@ -20,6 +19,7 @@ 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 .usage import TokenUsage if TYPE_CHECKING: @@ -30,6 +30,7 @@ if TYPE_CHECKING: from .client import ChatClient from .events import AgentEvent + from .todo_nag import TodoNagStrategy from .todo_stack import TodoStack from .tools import ToolRegistry @@ -121,6 +122,7 @@ class ChatAgent: parallel_tools: bool max_context_tokens: int todo_stack: TodoStack | None + todo_nag_strategy: TodoNagStrategy messages: list[AnyChatMessage] session_usage: TokenUsage last_turn_usage: TokenUsage @@ -147,6 +149,7 @@ class ChatAgent: parallel_tools: bool = True, max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS, todo_stack: TodoStack | None = None, + todo_nag_strategy: str | TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY, ) -> None: self.client = client self.model = model @@ -162,6 +165,7 @@ class ChatAgent: self.parallel_tools = parallel_tools self.max_context_tokens = max_context_tokens self.todo_stack = todo_stack + self.todo_nag_strategy = parse_todo_nag_strategy(str(todo_nag_strategy)) self.messages = list(messages) if messages is not None else [] self.session_usage = TokenUsage() self.last_turn_usage = TokenUsage() @@ -298,7 +302,12 @@ class ChatAgent: self.todo_stack.begin_turn() # Non-empty stack at turn start → remind the model before first completion. if not self.todo_stack.is_empty(): - self.messages.append(DeveloperChatMessage(content=self.todo_stack.turn_reminder_prompt())) + _ = inject_todo_nag_for_stack( + self.messages, + self.todo_stack, + kind="turn_start", + strategy=self.todo_nag_strategy, + ) completed = False turn_usage = TokenUsage() @@ -318,6 +327,7 @@ class ChatAgent: parallel_tools=self.parallel_tools, max_context_tokens=self.max_context_tokens, todo_stack=self.todo_stack, + todo_nag_strategy=self.todo_nag_strategy, ): if isinstance(event, UsageEvent): turn_rounds += 1 diff --git a/src/plyngent/agent/loop.py b/src/plyngent/agent/loop.py index da840cb..4144030 100644 --- a/src/plyngent/agent/loop.py +++ b/src/plyngent/agent/loop.py @@ -13,7 +13,6 @@ from plyngent.lmproto.openai_compatible.model import ( AssistantChatMessage, AssistantFunctionToolCall, ChatCompletionsParam, - DeveloperChatMessage, StreamOptions, StreamToolCallDelta, ToolChatMessage, @@ -38,6 +37,7 @@ from .events import ( ToolResultEvent, UsageEvent, ) +from .todo_nag import DEFAULT_TODO_NAG_STRATEGY, inject_todo_nag_for_stack from .usage import resolve_round_usage, token_usage_from_api if TYPE_CHECKING: @@ -46,6 +46,7 @@ if TYPE_CHECKING: from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem from .client import ChatClient + from .todo_nag import TodoNagStrategy from .todo_stack import TodoStack from .tools import ToolRegistry @@ -307,7 +308,7 @@ def _last_assistant(messages: list[AnyChatMessage], pre_len: int) -> AssistantCh return last -async def run_chat_loop( +async def run_chat_loop( # noqa: C901 — multi-phase tool loop client: ChatClient, messages: list[AnyChatMessage], *, @@ -321,6 +322,7 @@ async def run_chat_loop( parallel_tools: bool = True, max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS, todo_stack: TodoStack | None = None, + todo_nag_strategy: TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY, ) -> AsyncIterator[AgentEvent]: """Multi-round chat/tool loop; mutates ``messages`` in place and yields events. @@ -330,8 +332,8 @@ async def run_chat_loop( Request payloads may shrink older tool results when over ``max_context_tokens``. When *todo_stack* is set and still needs review after a natural stop - (open items, or non-empty stack untouched this turn), injects a developer - review message and continues once so the model reconciles unfinished work. + (open items, or non-empty stack untouched this turn), injects a review nag + (channel from *todo_nag_strategy*) once so the model reconciles unfinished work. """ tool_items: Sequence[AnyToolItem] | None = None if tools is not None and len(tools) > 0: @@ -373,9 +375,14 @@ async def run_chat_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 - # Non-user control identity so retry/history don't treat this as human input. - messages.append(DeveloperChatMessage(content=todo_stack.review_prompt())) - continue + injected = inject_todo_nag_for_stack( + messages, + todo_stack, + kind="end_of_turn", + strategy=todo_nag_strategy, + ) + if injected: + continue return async for event in _execute_tool_calls( tools, diff --git a/src/plyngent/agent/todo_nag.py b/src/plyngent/agent/todo_nag.py new file mode 100644 index 0000000..baed018 --- /dev/null +++ b/src/plyngent/agent/todo_nag.py @@ -0,0 +1,106 @@ +"""Inject todo stack nags into the message list (configurable channel).""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING, Literal, cast + +from msgspec import UNSET + +from plyngent.lmproto.openai_compatible.model import ( + AssistantChatMessage, + AssistantFunctionTool, + AssistantFunctionToolCall, + DeveloperChatMessage, + SystemChatMessage, + ToolChatMessage, + UserChatMessage, +) + +if TYPE_CHECKING: + from plyngent.lmproto.openai_compatible.model import AnyChatMessage + + from .todo_stack import TodoStack + +type TodoNagStrategy = Literal["developer", "system", "user", "synthetic_tool", "none"] +type TodoNagKind = Literal["turn_start", "end_of_turn"] + +TODO_NAG_STRATEGIES: frozenset[str] = frozenset({"developer", "system", "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.""" + token = (raw or DEFAULT_TODO_NAG_STRATEGY).strip().lower().replace("-", "_") + if token not in TODO_NAG_STRATEGIES: + return DEFAULT_TODO_NAG_STRATEGY + return cast("TodoNagStrategy", token) + + +def nag_body(stack: TodoStack, kind: TodoNagKind) -> str: + if kind == "turn_start": + return stack.turn_reminder_prompt() + return stack.review_prompt() + + +def _append_synthetic_todo_list(messages: list[AnyChatMessage], body: str) -> None: + call_id = f"todo-nag-{uuid.uuid4().hex[:12]}" + messages.append( + AssistantChatMessage( + content=UNSET, + tool_calls=[ + AssistantFunctionToolCall( + id=call_id, + function=AssistantFunctionTool( + name=_SYNTHETIC_TOOL_NAME, + arguments="{}", + ), + ) + ], + ) + ) + messages.append(ToolChatMessage(tool_call_id=call_id, content=body)) + + +def inject_todo_nag( + messages: list[AnyChatMessage], + body: str, + *, + strategy: TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY, +) -> bool: + """Append a todo nag using *strategy*. Returns True if anything was appended. + + 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 + + +def inject_todo_nag_for_stack( + messages: list[AnyChatMessage], + stack: TodoStack, + *, + kind: TodoNagKind, + 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) diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index b2471d5..13a9621 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -187,6 +187,7 @@ class ReplState: parallel_tools=agent_cfg.parallel_tools, max_context_tokens=agent_cfg.max_context_tokens, todo_stack=self.todo_stack, + todo_nag_strategy=agent_cfg.todo_nag_strategy, ) def rebuild_client(self) -> None: diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index 2e319f8..3b7db13 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -27,6 +27,10 @@ class AgentConfig(Struct, omit_defaults=True): path_denylist: list[str] = field(default_factory=list) 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 + todo_nag_strategy: str = "developer" + # Compact / summarisation prompts (empty = use built-in defaults). compact_system_prompt: str = "" compact_user_prefix: str = "" diff --git a/tests/test_agent/test_todo_stack.py b/tests/test_agent/test_todo_stack.py index 5eb3fd7..a97bb97 100644 --- a/tests/test_agent/test_todo_stack.py +++ b/tests/test_agent/test_todo_stack.py @@ -3,17 +3,22 @@ from __future__ import annotations from typing import TYPE_CHECKING, Literal, overload import pytest +from msgspec import UNSET from plyngent.agent import ChatAgent, ToolRegistry +from plyngent.agent.todo_nag import inject_todo_nag, parse_todo_nag_strategy from plyngent.agent.todo_stack import TodoStack, parse_push_titles from plyngent.config.models import DatabaseConfig from plyngent.lmproto.openai_compatible.model import ( + AnyChatMessage, AssistantChatMessage, ChatCompletionChoice, ChatCompletionChunk, ChatCompletionResponse, ChatCompletionsParam, DeveloperChatMessage, + SystemChatMessage, + ToolChatMessage, UserChatMessage, ) from plyngent.memory import MemoryStore @@ -117,6 +122,43 @@ def test_todo_stack_needs_review() -> None: assert not stack.needs_review() +def test_parse_todo_nag_strategy() -> None: + assert parse_todo_nag_strategy("developer") == "developer" + assert parse_todo_nag_strategy("SYNTHETIC-TOOL") == "synthetic_tool" + assert parse_todo_nag_strategy("nope") == "developer" + assert parse_todo_nag_strategy(None) == "developer" + + +def test_inject_todo_nag_strategies() -> None: + body = "[TODO OPEN WORK] test" + messages: list[AnyChatMessage] = [] + assert inject_todo_nag(messages, body, strategy="none") is False + assert messages == [] + + messages = [] + assert inject_todo_nag(messages, body, strategy="developer") is True + 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 + 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 + assert messages[1].tool_call_id.startswith("todo-nag-") + + def test_todo_prompts_signal_undone_work() -> None: stack = TodoStack() item = stack.push("open work") @@ -293,3 +335,55 @@ async def test_loop_injects_todo_review_when_open_after_touch() -> None: assert any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages) finally: set_todo_stack(None) + + +@pytest.mark.asyncio +async def test_loop_synthetic_tool_nag_strategy() -> None: + stack = TodoStack() + _ = stack.push("open work") + stack.begin_turn() + client = ScriptedClient() + agent = ChatAgent( + client, # type: ignore[arg-type] + model="m", + tools=ToolRegistry(list(TODO_TOOLS)), + stream=False, + todo_stack=stack, + todo_nag_strategy="synthetic_tool", + ) + set_todo_stack(stack) + 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) + assert not any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages) + finally: + set_todo_stack(None) + + +@pytest.mark.asyncio +async def test_loop_none_nag_strategy_skips_inject() -> None: + stack = TodoStack() + _ = stack.push("open work") + stack.begin_turn() + client = ScriptedClient() + agent = ChatAgent( + client, # type: ignore[arg-type] + model="m", + tools=ToolRegistry(list(TODO_TOOLS)), + stream=False, + todo_stack=stack, + todo_nag_strategy="none", + ) + set_todo_stack(stack) + try: + async for _event in agent.run("do stuff"): + pass + # One completion only — no end-of-turn continue from nag. + assert client.calls == 1 + assert not any( + isinstance(m, (DeveloperChatMessage, ToolChatMessage)) and "[TODO OPEN WORK]" in getattr(m, "content", "") + for m in agent.messages + ) + finally: + set_todo_stack(None)