core/agent: stronger open-work signals in todo stack prompts

Split tags: [TODO OPEN WORK] for pending/in_progress (undone work) vs
[TODO HYGIENE] for all-terminal leftover groups. End-of-turn review lists
open titles; tool docs stress open items as unfinished work.
This commit is contained in:
2026-07-20 03:17:44 +08:00
parent 0d783b6d96
commit a4ab11c9a2
4 changed files with 54 additions and 34 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). - 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. - **`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). - **`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; non-empty stack = unfinished/unreconciled work (turn-start developer reminder + end-of-turn review if still open or untouched). - **`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]`).
- **`DEFAULT_TOOLS`**: file + process + vcs + chat + todo tool list for a `ToolRegistry`. - **`DEFAULT_TOOLS`**: file + process + vcs + chat + todo tool list for a `ToolRegistry`.
### Prompting (`prompting.py`) ### Prompting (`prompting.py`)
+35 -18
View File
@@ -8,6 +8,7 @@ from msgspec import Struct, field
type TodoStatus = Literal["pending", "in_progress", "done", "cancelled"] type TodoStatus = Literal["pending", "in_progress", "done", "cancelled"]
_OPEN: frozenset[str] = frozenset({"pending", "in_progress"}) _OPEN: frozenset[str] = frozenset({"pending", "in_progress"})
_REVIEW_OPEN_TITLE_LIMIT = 8
class TodoItem(Struct, omit_defaults=True): class TodoItem(Struct, omit_defaults=True):
@@ -184,24 +185,38 @@ class TodoStack:
return "\n".join(lines) return "\n".join(lines)
def turn_reminder_prompt(self) -> str: def turn_reminder_prompt(self) -> str:
"""Short mid-context nudge when a turn starts with a non-empty stack.""" """Mid-context nudge when a turn starts with a non-empty stack.
A non-empty stack usually means unfinished work (open items) or unfinished
stack hygiene (terminal items still grouped). Prefer finishing real work
over pure bookkeeping when both exist.
"""
n_open = len(self.open_items()) n_open = len(self.open_items())
n_groups = self.depth n_groups = self.depth
if n_open: if n_open:
headline = ( headline = (
f"[TODO REMINDER] Stack not empty: {n_open} open item(s) across " f"[TODO OPEN WORK] Stack not empty: {n_open} open item(s) across "
f"{n_groups} group(s). Open items usually mean unfinished work from " f"{n_groups} group(s). This is unfinished work from earlier in the "
"earlier in the session — continue them, update status, or clear only " "session — not optional decoration."
"if intentionally abandoned." )
action = (
"Treat open items as active commitments: continue them, mark "
"done/cancelled when finished, or push a child breakdown for the "
"current TOP item. Only todo_clear if the user abandoned the plan. "
"Do not ignore the stack while answering unrelated chatter."
) )
else: else:
headline = ( headline = (
f"[TODO REMINDER] Stack not empty: {n_groups} group(s) with no open " f"[TODO HYGIENE] Stack not empty: {n_groups} group(s) with no open "
"items (all done/cancelled). Pop finished TOP groups or todo_clear if " "items (all done/cancelled). Work may be finished; the stack is not."
"the plan is complete — do not leave stale groups behind." )
action = (
"Pop finished TOP groups (or todo_clear when the whole plan is done). "
"Leaving only-terminal groups wastes context; clean up when you can."
) )
lines = [ lines = [
headline, headline,
action,
"Tools: todo_list | todo_push(titles=[...]) | todo_update | todo_pop | todo_clear", "Tools: todo_list | todo_push(titles=[...]) | todo_update | todo_pop | todo_clear",
"Rules: TOP = current level; push = one sibling group; pop = whole TOP group.", "Rules: TOP = current level; push = one sibling group; pop = whole TOP group.",
"Stack:", "Stack:",
@@ -215,25 +230,27 @@ class TodoStack:
n_open = len(open_items) n_open = len(open_items)
n_groups = self.depth n_groups = self.depth
if n_open: if n_open:
open_titles = "; ".join(f"{item.id}:{item.title}" for item in open_items[:_REVIEW_OPEN_TITLE_LIMIT])
if n_open > _REVIEW_OPEN_TITLE_LIMIT:
open_titles += f"; …(+{n_open - _REVIEW_OPEN_TITLE_LIMIT} more)"
headline = ( headline = (
f"[TODO OPEN] Stack not empty: {n_open} open item(s) across " f"[TODO OPEN WORK] You stopped with {n_open} open item(s) still on "
f"{n_groups} group(s). You stopped while work may still be incomplete." f"the stack ({n_groups} group(s)). That usually means undone work."
) )
action = ( action = (
"Do not end the turn with open tasks unaddressed: mark done/cancelled, " "Do not end the turn while open tasks remain unaddressed. Next: "
"pop finished TOP groups, push a child breakdown, or clear only if the " "resume an open item, mark done/cancelled, pop a finished TOP group, "
"user no longer wants the plan. Open stack items are a strong signal of " "or push a child breakdown. Clear only if the user no longer wants "
"undone work." f"the plan. Open: {open_titles}"
) )
else: else:
headline = ( headline = (
f"[TODO OPEN] Stack not empty: {n_groups} group(s) remain but every " f"[TODO HYGIENE] Stack still has {n_groups} group(s) but every item "
"item is done/cancelled. Bookkeeping is unfinished." "is done/cancelled. Real work looks finished; stack cleanup does not."
) )
action = ( action = (
"Pop finished TOP groups (or todo_clear when the whole plan is done). " "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 " "A non-empty all-terminal stack is unfinished task hygiene, not new work."
"task hygiene."
) )
lines = [ lines = [
headline, headline,
+6 -5
View File
@@ -42,9 +42,9 @@ def _notify() -> None:
def todo_list() -> str: def todo_list() -> str:
"""Show the LIFO stack of **task groups** (TOP group = current breakdown level). """Show the LIFO stack of **task groups** (TOP group = current breakdown level).
A non-empty stack usually means unfinished or unreconciled work — keep Non-empty stack with open (pending/in_progress) items = unfinished work.
updating statuses, pop finished TOP groups, or clear only when abandoning. All-terminal but non-empty = hygiene only (pop/clear). Push creates one
Push creates one group of siblings; pop removes the whole top group. group of siblings; pop removes the whole top group.
Pattern: push [T1,T2] → push [T1.1,T1.2] → finish children → pop → push [T2.1]… Pattern: push [T1,T2] → push [T1.1,T1.2] → finish children → pop → push [T2.1]…
""" """
stack = _require_stack() stack = _require_stack()
@@ -101,8 +101,9 @@ def todo_update(
) -> str: ) -> str:
"""Update a task by id inside any group. """Update a task by id inside any group.
Open (pending/in_progress) items signal unfinished work mark done/cancelled Open items are unfinished work: mark done/cancelled when the work is truly
when finished. Pop the TOP group when that breakdown level is complete. finished (not just deferred). Pop the TOP group when that breakdown level
is complete so the stack does not linger as false open work.
""" """
stack = _require_stack() stack = _require_stack()
status_arg: TodoStatus | None = None status_arg: TodoStatus | None = None
+12 -10
View File
@@ -121,20 +121,23 @@ def test_todo_prompts_signal_undone_work() -> None:
stack = TodoStack() stack = TodoStack()
item = stack.push("open work") item = stack.push("open work")
reminder = stack.turn_reminder_prompt() reminder = stack.turn_reminder_prompt()
assert "[TODO REMINDER]" in reminder assert "[TODO OPEN WORK]" in reminder
assert "Stack not empty" in reminder assert "Stack not empty" in reminder
assert "unfinished" in reminder.lower() assert "unfinished work" in reminder.lower()
assert "open work" in reminder assert "open work" in reminder
review = stack.review_prompt() review = stack.review_prompt()
assert "[TODO OPEN]" in review assert "[TODO OPEN WORK]" in review
assert "Stack not empty" in review assert "undone work" in review.lower() or "open item" in review.lower()
assert "undone" in review.lower() or "incomplete" in review.lower()
assert "open work" in review assert "open work" in review
assert "t1:open work" in review or "open work" in review
stack.update(item.id, status="done") stack.update(item.id, status="done")
terminal_review = stack.review_prompt() terminal_review = stack.review_prompt()
assert "done/cancelled" in terminal_review or "Bookkeeping" in terminal_review assert "[TODO HYGIENE]" in terminal_review
assert "done/cancelled" in terminal_review
terminal_reminder = stack.turn_reminder_prompt()
assert "[TODO HYGIENE]" in terminal_reminder
def test_legacy_flat_and_frames_migrate() -> None: def test_legacy_flat_and_frames_migrate() -> None:
@@ -259,9 +262,8 @@ async def test_loop_injects_todo_review_when_untouched() -> None:
async for _event in agent.run("do stuff"): async for _event in agent.run("do stuff"):
pass pass
assert client.calls >= 2 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 WORK]" 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 WORK]" 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: finally:
set_todo_stack(None) set_todo_stack(None)
@@ -288,6 +290,6 @@ async def test_loop_injects_todo_review_when_open_after_touch() -> None:
async for _event in agent.run("do stuff"): async for _event in agent.run("do stuff"):
pass pass
assert client.calls >= 2 assert client.calls >= 2
assert any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN]" in m.content for m in agent.messages) assert any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages)
finally: finally:
set_todo_stack(None) set_todo_stack(None)