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.
This commit is contained in:
2026-07-19 20:34:01 +08:00
parent a54fa534f3
commit e1e4df0f3f
6 changed files with 138 additions and 12 deletions
+1 -1
View File
@@ -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`)
+4
View File
@@ -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()
+3 -3
View File
@@ -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:
+62 -5
View File
@@ -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)
+12 -2
View File
@@ -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():
+56 -1
View File
@@ -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)