mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +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:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user