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).
- **`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; 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`.
### Prompting (`prompting.py`)
+35 -18
View File
@@ -8,6 +8,7 @@ from msgspec import Struct, field
type TodoStatus = Literal["pending", "in_progress", "done", "cancelled"]
_OPEN: frozenset[str] = frozenset({"pending", "in_progress"})
_REVIEW_OPEN_TITLE_LIMIT = 8
class TodoItem(Struct, omit_defaults=True):
@@ -184,24 +185,38 @@ class TodoStack:
return "\n".join(lines)
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_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."
f"[TODO OPEN WORK] Stack not empty: {n_open} open item(s) across "
f"{n_groups} group(s). This is unfinished work from earlier in the "
"session — not optional decoration."
)
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:
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."
f"[TODO HYGIENE] Stack not empty: {n_groups} group(s) with no open "
"items (all done/cancelled). Work may be finished; the stack is not."
)
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 = [
headline,
action,
"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:",
@@ -215,25 +230,27 @@ class TodoStack:
n_open = len(open_items)
n_groups = self.depth
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 = (
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."
f"[TODO OPEN WORK] You stopped with {n_open} open item(s) still on "
f"the stack ({n_groups} group(s)). That usually means undone work."
)
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."
"Do not end the turn while open tasks remain unaddressed. Next: "
"resume an open item, mark done/cancelled, pop a finished TOP group, "
"or push a child breakdown. Clear only if the user no longer wants "
f"the plan. Open: {open_titles}"
)
else:
headline = (
f"[TODO OPEN] Stack not empty: {n_groups} group(s) remain but every "
"item is done/cancelled. Bookkeeping is unfinished."
f"[TODO HYGIENE] Stack still has {n_groups} group(s) but every item "
"is done/cancelled. Real work looks finished; stack cleanup does not."
)
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."
"A non-empty all-terminal stack is unfinished task hygiene, not new work."
)
lines = [
headline,
+6 -5
View File
@@ -42,9 +42,9 @@ 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.
Non-empty stack with open (pending/in_progress) items = unfinished work.
All-terminal but non-empty = hygiene only (pop/clear). 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]…
"""
stack = _require_stack()
@@ -101,8 +101,9 @@ def todo_update(
) -> str:
"""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.
Open items are unfinished work: mark done/cancelled when the work is truly
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()
status_arg: TodoStatus | None = None
+12 -10
View File
@@ -121,20 +121,23 @@ 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 "[TODO OPEN WORK]" in reminder
assert "Stack not empty" in reminder
assert "unfinished" in reminder.lower()
assert "unfinished work" 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 "[TODO OPEN WORK]" in review
assert "undone work" in review.lower() or "open item" in review.lower()
assert "open work" in review
assert "t1:open work" in review or "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
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:
@@ -259,9 +262,8 @@ 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)
assert any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN WORK]" 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)
finally:
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"):
pass
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:
set_todo_stack(None)