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