mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
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:
@@ -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`)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user