From e1e4df0f3fc04b400ee3b2294aedfbd0c0411e54 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Sun, 19 Jul 2026 20:34:01 +0800 Subject: [PATCH] core/agent: strengthen todo prompts when stack not empty Inject turn-start [TODO REMINDER] for non-empty stacks; rewrite end-of-turn [TODO OPEN] copy so open items read as unfinished work. needs_review now always true while open items remain (even if todo_* ran this turn); terminal-only stacks still need review if untouched. Update tool docs and tests. --- CLAUDE.md | 2 +- src/plyngent/agent/chat.py | 4 ++ src/plyngent/agent/loop.py | 6 +-- src/plyngent/agent/todo_stack.py | 67 ++++++++++++++++++++++++++--- src/plyngent/tools/todo.py | 14 +++++- tests/test_agent/test_todo_stack.py | 57 +++++++++++++++++++++++- 6 files changed, 138 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4318bf6..5bd6995 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; agent injects a developer review message if open todos and no todo tool use in the turn. +- **`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; non-empty stack = unfinished/unreconciled work (turn-start developer reminder + end-of-turn review if still open or untouched). - **`DEFAULT_TOOLS`**: file + process + vcs + chat + todo tool list for a `ToolRegistry`. ### Prompting (`prompting.py`) diff --git a/src/plyngent/agent/chat.py b/src/plyngent/agent/chat.py index 0f13aa6..b9c577f 100644 --- a/src/plyngent/agent/chat.py +++ b/src/plyngent/agent/chat.py @@ -7,6 +7,7 @@ from msgspec import UNSET from plyngent.lmproto.openai_compatible.model import ( AssistantChatMessage, AssistantFunctionToolCall, + DeveloperChatMessage, SystemChatMessage, ToolChatMessage, UserChatMessage, @@ -295,6 +296,9 @@ 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(): + self.messages.append(DeveloperChatMessage(content=self.todo_stack.turn_reminder_prompt())) completed = False turn_usage = TokenUsage() diff --git a/src/plyngent/agent/loop.py b/src/plyngent/agent/loop.py index 2446155..da840cb 100644 --- a/src/plyngent/agent/loop.py +++ b/src/plyngent/agent/loop.py @@ -329,9 +329,9 @@ async def run_chat_loop( Multiple tool calls in one round run in parallel when ``parallel_tools``. Request payloads may shrink older tool results when over ``max_context_tokens``. - When *todo_stack* is set and still has open items after a natural stop with - no ``todo_*`` tool use this turn, injects a review user message and continues - once (so the model must check or update the stack). + 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. """ tool_items: Sequence[AnyToolItem] | None = None if tools is not None and len(tools) > 0: diff --git a/src/plyngent/agent/todo_stack.py b/src/plyngent/agent/todo_stack.py index 0428421..e35bcad 100644 --- a/src/plyngent/agent/todo_stack.py +++ b/src/plyngent/agent/todo_stack.py @@ -88,7 +88,17 @@ class TodoStack: return not self._data.groups def needs_review(self) -> bool: - return bool(self.open_items()) and not self._touched_this_turn + """True when the stack still signals unfinished or unreconciled work. + + Open (pending/in_progress) items always need attention. A non-empty stack + with only terminal items still needs a pop/clear if the agent ignored + todos this turn. + """ + if self.is_empty(): + return False + if self.open_items(): + return True + return not self._touched_this_turn def to_data(self) -> TodoStackData: return self._data @@ -173,17 +183,64 @@ class TodoStack: lines.append(f" {mark} {item.id}: {item.title}{note}") return "\n".join(lines) + def turn_reminder_prompt(self) -> str: + """Short mid-context nudge when a turn starts with a non-empty stack.""" + n_open = len(self.open_items()) + n_groups = self.depth + if n_open: + headline = ( + f"[TODO REMINDER] Stack not empty: {n_open} open item(s) across " + f"{n_groups} group(s). Open items usually mean unfinished work from " + "earlier in the session — continue them, update status, or clear only " + "if intentionally abandoned." + ) + else: + headline = ( + f"[TODO REMINDER] Stack not empty: {n_groups} group(s) with no open " + "items (all done/cancelled). Pop finished TOP groups or todo_clear if " + "the plan is complete — do not leave stale groups behind." + ) + lines = [ + headline, + "Tools: todo_list | todo_push(titles=[...]) | todo_update | todo_pop | todo_clear", + "Rules: TOP = current level; push = one sibling group; pop = whole TOP group.", + "Stack:", + self.render(), + ] + return "\n".join(lines) + def review_prompt(self) -> str: - """Compact, model-facing nag: open work still exists this turn.""" + """End-of-turn nag: non-empty stack still signals unfinished work.""" open_items = self.open_items() n_open = len(open_items) n_groups = self.depth + if n_open: + headline = ( + f"[TODO OPEN] Stack not empty: {n_open} open item(s) across " + f"{n_groups} group(s). You stopped while work may still be incomplete." + ) + action = ( + "Do not end the turn with open tasks unaddressed: mark done/cancelled, " + "pop finished TOP groups, push a child breakdown, or clear only if the " + "user no longer wants the plan. Open stack items are a strong signal of " + "undone work." + ) + else: + headline = ( + f"[TODO OPEN] Stack not empty: {n_groups} group(s) remain but every " + "item is done/cancelled. Bookkeeping is unfinished." + ) + action = ( + "Pop finished TOP groups (or todo_clear when the whole plan is done). " + "A non-empty stack after all items are terminal still means unfinished " + "task hygiene." + ) lines = [ - f"[TODO OPEN] {n_open} open item(s) across {n_groups} group(s) " - f"(not empty). You did not call todo_* this turn.", + headline, + action, "Tools: todo_list | todo_push(titles=[...]) | todo_update | todo_pop | todo_clear", "Rules: TOP group = current level; push=one sibling group; pop=remove whole TOP group.", - "Act on open work before ending (finish/pop TOP, or push child tasks). Stack:", + "Stack:", self.render(), ] return "\n".join(lines) diff --git a/src/plyngent/tools/todo.py b/src/plyngent/tools/todo.py index c9e8835..d54c00e 100644 --- a/src/plyngent/tools/todo.py +++ b/src/plyngent/tools/todo.py @@ -42,6 +42,8 @@ def _notify() -> None: def todo_list() -> str: """Show the LIFO stack of **task groups** (TOP group = current breakdown level). + A non-empty stack usually means unfinished or unreconciled work — keep + updating statuses, pop finished TOP groups, or clear only when abandoning. Push creates one group of siblings; pop removes the whole top group. Pattern: push [T1,T2] → push [T1.1,T1.2] → finish children → pop → push [T2.1]… """ @@ -74,7 +76,11 @@ def todo_push(titles: list[str], notes: str = "") -> str: @tool(name="todo_pop") def todo_pop() -> str: - """Pop the entire **top group** (all siblings from that push).""" + """Pop the entire **top group** (all siblings from that push). + + Prefer after TOP items are done/cancelled so the stack does not stay + non-empty with only finished work. + """ stack = _require_stack() group = stack.pop() if group is None: @@ -93,7 +99,11 @@ def todo_update( title: str = "", notes: str = "", ) -> str: - """Update a task by id inside any group. Pop the group when that level is done.""" + """Update a task by id inside any group. + + Open (pending/in_progress) items signal unfinished work — mark done/cancelled + when finished. Pop the TOP group when that breakdown level is complete. + """ stack = _require_stack() status_arg: TodoStatus | None = None if status.strip(): diff --git a/tests/test_agent/test_todo_stack.py b/tests/test_agent/test_todo_stack.py index cbea337..b5326a3 100644 --- a/tests/test_agent/test_todo_stack.py +++ b/tests/test_agent/test_todo_stack.py @@ -103,13 +103,40 @@ def test_single_title_still_one_group() -> None: def test_todo_stack_needs_review() -> None: stack = TodoStack() assert not stack.needs_review() - _ = stack.push("work") + item = stack.push("work") stack.begin_turn() + # Open items always need review, even if todo_* was used this turn. + assert stack.needs_review() + stack.mark_touched() + assert stack.needs_review() + stack.update(item.id, status="done") + stack.begin_turn() + # Terminal-only stack: review only when untouched this turn. assert stack.needs_review() stack.mark_touched() assert not stack.needs_review() +def test_todo_prompts_signal_undone_work() -> None: + stack = TodoStack() + item = stack.push("open work") + reminder = stack.turn_reminder_prompt() + assert "[TODO REMINDER]" in reminder + assert "Stack not empty" in reminder + assert "unfinished" in reminder.lower() + assert "open work" in reminder + + review = stack.review_prompt() + assert "[TODO OPEN]" in review + assert "Stack not empty" in review + assert "undone" in review.lower() or "incomplete" in review.lower() + assert "open work" in review + + stack.update(item.id, status="done") + terminal_review = stack.review_prompt() + assert "done/cancelled" in terminal_review or "Bookkeeping" in terminal_review + + def test_legacy_flat_and_frames_migrate() -> None: flat = TodoStack.from_raw( { @@ -232,7 +259,35 @@ async def test_loop_injects_todo_review_when_untouched() -> None: async for _event in agent.run("do stuff"): pass assert client.calls >= 2 + assert any(isinstance(m, DeveloperChatMessage) and "[TODO REMINDER]" in m.content for m in agent.messages) assert any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN]" in m.content for m in agent.messages) assert not any(isinstance(m, UserChatMessage) and "[TODO OPEN]" in m.content for m in agent.messages) finally: set_todo_stack(None) + + +@pytest.mark.asyncio +async def test_loop_injects_todo_review_when_open_after_touch() -> None: + """Open items still trigger end-of-turn review even if todo_* ran this turn.""" + stack = TodoStack() + _ = stack.push("still open") + stack.begin_turn() + stack.mark_touched() # simulates todo_list earlier in the turn + assert stack.needs_review() + + client = ScriptedClient() + agent = ChatAgent( + client, # type: ignore[arg-type] + model="m", + tools=ToolRegistry(list(TODO_TOOLS)), + stream=False, + todo_stack=stack, + ) + set_todo_stack(stack) + try: + async for _event in agent.run("do stuff"): + pass + assert client.calls >= 2 + assert any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN]" in m.content for m in agent.messages) + finally: + set_todo_stack(None)