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:
2026-07-19 20:34:01 +08:00
parent a54fa534f3
commit e1e4df0f3f
6 changed files with 138 additions and 12 deletions
+4
View File
@@ -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()
+3 -3
View File
@@ -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:
+62 -5
View File
@@ -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)
+12 -2
View File
@@ -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():