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).
|
- 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; 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`.
|
- **`DEFAULT_TOOLS`**: file + process + vcs + chat + todo tool list for a `ToolRegistry`.
|
||||||
|
|
||||||
### Prompting (`prompting.py`)
|
### Prompting (`prompting.py`)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from msgspec import UNSET
|
|||||||
from plyngent.lmproto.openai_compatible.model import (
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
AssistantChatMessage,
|
AssistantChatMessage,
|
||||||
AssistantFunctionToolCall,
|
AssistantFunctionToolCall,
|
||||||
|
DeveloperChatMessage,
|
||||||
SystemChatMessage,
|
SystemChatMessage,
|
||||||
ToolChatMessage,
|
ToolChatMessage,
|
||||||
UserChatMessage,
|
UserChatMessage,
|
||||||
@@ -295,6 +296,9 @@ class ChatAgent:
|
|||||||
user_index = self._user_index(user_msg)
|
user_index = self._user_index(user_msg)
|
||||||
if self.todo_stack is not None:
|
if self.todo_stack is not None:
|
||||||
self.todo_stack.begin_turn()
|
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
|
completed = False
|
||||||
turn_usage = TokenUsage()
|
turn_usage = TokenUsage()
|
||||||
|
|||||||
@@ -329,9 +329,9 @@ async def run_chat_loop(
|
|||||||
Multiple tool calls in one round run in parallel when ``parallel_tools``.
|
Multiple tool calls in one round run in parallel when ``parallel_tools``.
|
||||||
Request payloads may shrink older tool results when over ``max_context_tokens``.
|
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
|
When *todo_stack* is set and still needs review after a natural stop
|
||||||
no ``todo_*`` tool use this turn, injects a review user message and continues
|
(open items, or non-empty stack untouched this turn), injects a developer
|
||||||
once (so the model must check or update the stack).
|
review message and continues once so the model reconciles unfinished work.
|
||||||
"""
|
"""
|
||||||
tool_items: Sequence[AnyToolItem] | None = None
|
tool_items: Sequence[AnyToolItem] | None = None
|
||||||
if tools is not None and len(tools) > 0:
|
if tools is not None and len(tools) > 0:
|
||||||
|
|||||||
@@ -88,7 +88,17 @@ class TodoStack:
|
|||||||
return not self._data.groups
|
return not self._data.groups
|
||||||
|
|
||||||
def needs_review(self) -> bool:
|
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:
|
def to_data(self) -> TodoStackData:
|
||||||
return self._data
|
return self._data
|
||||||
@@ -173,17 +183,64 @@ class TodoStack:
|
|||||||
lines.append(f" {mark} {item.id}: {item.title}{note}")
|
lines.append(f" {mark} {item.id}: {item.title}{note}")
|
||||||
return "\n".join(lines)
|
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:
|
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()
|
open_items = self.open_items()
|
||||||
n_open = len(open_items)
|
n_open = len(open_items)
|
||||||
n_groups = self.depth
|
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 = [
|
lines = [
|
||||||
f"[TODO OPEN] {n_open} open item(s) across {n_groups} group(s) "
|
headline,
|
||||||
f"(not empty). You did not call todo_* this turn.",
|
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 group = current level; push=one sibling group; pop=remove whole TOP group.",
|
"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(),
|
self.render(),
|
||||||
]
|
]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ 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
|
||||||
|
updating statuses, pop finished TOP groups, or clear only when abandoning.
|
||||||
Push creates one group of siblings; pop removes the whole top group.
|
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]…
|
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")
|
@tool(name="todo_pop")
|
||||||
def todo_pop() -> str:
|
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()
|
stack = _require_stack()
|
||||||
group = stack.pop()
|
group = stack.pop()
|
||||||
if group is None:
|
if group is None:
|
||||||
@@ -93,7 +99,11 @@ def todo_update(
|
|||||||
title: str = "",
|
title: str = "",
|
||||||
notes: str = "",
|
notes: str = "",
|
||||||
) -> 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()
|
stack = _require_stack()
|
||||||
status_arg: TodoStatus | None = None
|
status_arg: TodoStatus | None = None
|
||||||
if status.strip():
|
if status.strip():
|
||||||
|
|||||||
@@ -103,13 +103,40 @@ def test_single_title_still_one_group() -> None:
|
|||||||
def test_todo_stack_needs_review() -> None:
|
def test_todo_stack_needs_review() -> None:
|
||||||
stack = TodoStack()
|
stack = TodoStack()
|
||||||
assert not stack.needs_review()
|
assert not stack.needs_review()
|
||||||
_ = stack.push("work")
|
item = stack.push("work")
|
||||||
stack.begin_turn()
|
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()
|
assert stack.needs_review()
|
||||||
stack.mark_touched()
|
stack.mark_touched()
|
||||||
assert not stack.needs_review()
|
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:
|
def test_legacy_flat_and_frames_migrate() -> None:
|
||||||
flat = TodoStack.from_raw(
|
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"):
|
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]" 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 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)
|
||||||
|
|
||||||
|
|
||||||
|
@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