core/agent+tools: session todo/task stack with review nag

Sub-task stack stored on session.todo_stack; model tools todo_*; slash
/todos for human ops; agent loop injects a review user message when open
todos exist and no todo_* tool ran this turn. Stack carries across /compact.
This commit is contained in:
2026-07-18 01:59:13 +08:00
parent 57236937eb
commit 596af1ea1b
12 changed files with 638 additions and 6 deletions
+7
View File
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
from .client import ChatClient
from .events import AgentEvent
from .todo_stack import TodoStack
from .tools import ToolRegistry
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
@@ -118,6 +119,7 @@ class ChatAgent:
max_tool_result_chars: int
parallel_tools: bool
max_context_tokens: int
todo_stack: TodoStack | None
messages: list[AnyChatMessage]
session_usage: TokenUsage
last_turn_usage: TokenUsage
@@ -143,6 +145,7 @@ class ChatAgent:
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
parallel_tools: bool = True,
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
todo_stack: TodoStack | None = None,
) -> None:
self.client = client
self.model = model
@@ -157,6 +160,7 @@ class ChatAgent:
self.max_tool_result_chars = max_tool_result_chars
self.parallel_tools = parallel_tools
self.max_context_tokens = max_context_tokens
self.todo_stack = todo_stack
self.messages = list(messages) if messages is not None else []
self.session_usage = TokenUsage()
self.last_turn_usage = TokenUsage()
@@ -289,6 +293,8 @@ class ChatAgent:
side-effecting tools. Unfinished assistant/stream suffix is rolled back.
"""
user_index = self._user_index(user_msg)
if self.todo_stack is not None:
self.todo_stack.begin_turn()
completed = False
turn_usage = TokenUsage()
@@ -307,6 +313,7 @@ class ChatAgent:
max_tool_result_chars=self.max_tool_result_chars,
parallel_tools=self.parallel_tools,
max_context_tokens=self.max_context_tokens,
todo_stack=self.todo_stack,
):
if isinstance(event, UsageEvent):
turn_rounds += 1
+16
View File
@@ -16,6 +16,7 @@ from plyngent.lmproto.openai_compatible.model import (
StreamOptions,
StreamToolCallDelta,
ToolChatMessage,
UserChatMessage,
)
from plyngent.typedef import Unset # noqa: TC001
@@ -45,6 +46,7 @@ if TYPE_CHECKING:
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
from .client import ChatClient
from .todo_stack import TodoStack
from .tools import ToolRegistry
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
@@ -318,6 +320,7 @@ async def run_chat_loop(
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
parallel_tools: bool = True,
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
todo_stack: TodoStack | None = None,
) -> AsyncIterator[AgentEvent]:
"""Multi-round chat/tool loop; mutates ``messages`` in place and yields events.
@@ -325,6 +328,10 @@ async def run_chat_loop(
text deltas as chunks arrive; tool calls are merged from stream deltas.
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).
"""
tool_items: Sequence[AnyToolItem] | None = None
if tools is not None and len(tools) > 0:
@@ -335,6 +342,7 @@ async def run_chat_loop(
# Calibrate soft-compact from last model call's prompt_tokens (API preferred).
prompt_tokens_hint: int | None = None
sent_estimate_tokens: int | None = None
todo_review_injected = False
while True:
while rounds_used < allowance:
@@ -363,6 +371,14 @@ async def run_chat_loop(
assistant = _last_assistant(messages, pre_len)
tool_calls = assistant.tool_calls
if tool_calls is UNSET or not tool_calls or tools is None:
if (
todo_stack is not None
and todo_stack.needs_review()
and not todo_review_injected
):
todo_review_injected = True
messages.append(UserChatMessage(content=todo_stack.review_prompt()))
continue
return
async for event in _execute_tool_calls(
tools,
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
from typing import Literal, cast
import msgspec
from msgspec import Struct, field
type TodoStatus = Literal["pending", "in_progress", "done", "cancelled"]
_OPEN: frozenset[str] = frozenset({"pending", "in_progress"})
class TodoItem(Struct, omit_defaults=True):
"""One sub-task on the session todo stack."""
id: str
title: str
status: TodoStatus = "pending"
notes: str = ""
class TodoStackData(Struct, omit_defaults=True):
"""Serializable stack body (session storage)."""
items: list[TodoItem] = field(default_factory=list)
next_id: int = 1
class TodoStack:
"""Session-local ordered sub-tasks for breaking work into steps.
Maintained mainly by model tools; humans can show/push/pop/clear via slash.
"""
def __init__(self, data: TodoStackData | None = None) -> None:
self._data: TodoStackData = data if data is not None else TodoStackData()
self._touched_this_turn: bool = False
@property
def items(self) -> list[TodoItem]:
return self._data.items
@property
def touched_this_turn(self) -> bool:
return self._touched_this_turn
def begin_turn(self) -> None:
"""Reset the per-turn touch flag (call at start of each user turn)."""
self._touched_this_turn = False
def mark_touched(self) -> None:
self._touched_this_turn = True
def open_items(self) -> list[TodoItem]:
return [item for item in self._data.items if item.status in _OPEN]
def is_empty(self) -> bool:
return not self._data.items
def needs_review(self) -> bool:
"""True when open work exists but no todo tool ran this turn."""
return bool(self.open_items()) and not self._touched_this_turn
def to_data(self) -> TodoStackData:
return self._data
@classmethod
def from_raw(cls, raw: object | None) -> TodoStack:
if raw is None:
return cls()
try:
data = msgspec.convert(raw, type=TodoStackData)
except (msgspec.ValidationError, TypeError, ValueError):
return cls()
if data.next_id < 1:
data = msgspec.structs.replace(data, next_id=1)
return cls(data)
def to_raw(self) -> dict[str, object]:
out: object = msgspec.to_builtins(self._data)
if not isinstance(out, dict):
return {"items": [], "next_id": 1}
raw = cast("dict[object, object]", out)
return {str(key): value for key, value in raw.items()}
def render(self) -> str:
if not self._data.items:
return "(todo stack empty)"
lines: list[str] = []
for item in self._data.items:
mark = {
"pending": "[ ]",
"in_progress": "[~]",
"done": "[x]",
"cancelled": "[-]",
}.get(item.status, "[?]")
note = f" — {item.notes}" if item.notes else ""
lines.append(f"{mark} {item.id}: {item.title}{note}")
return "\n".join(lines)
def review_prompt(self) -> str:
"""User-message body injected when the model finishes without todo ops."""
return (
"[system: todo stack review]\n"
"Open sub-tasks remain and you did not call any todo_* tools this turn.\n"
"Review the stack: mark finished items done, update in_progress, "
"push new sub-tasks, or pop/cancel obsolete ones. Then continue the user work.\n\n"
f"Current stack:\n{self.render()}"
)
def push(self, title: str, *, notes: str = "", status: TodoStatus = "pending") -> TodoItem:
token = title.strip()
if not token:
msg = "title must not be empty"
raise ValueError(msg)
item_id = f"t{self._data.next_id}"
self._data.next_id += 1
item = TodoItem(id=item_id, title=token, status=status, notes=notes.strip())
self._data.items.append(item)
self.mark_touched()
return item
def pop(self) -> TodoItem | None:
"""Remove and return the last open item, else the last item, else None."""
if not self._data.items:
return None
open_ids = {i.id for i in self.open_items()}
for index in range(len(self._data.items) - 1, -1, -1):
if self._data.items[index].id in open_ids:
item = self._data.items.pop(index)
self.mark_touched()
return item
item = self._data.items.pop()
self.mark_touched()
return item
def clear(self) -> int:
n = len(self._data.items)
self._data.items.clear()
if n:
self.mark_touched()
return n
def get(self, item_id: str) -> TodoItem | None:
for item in self._data.items:
if item.id == item_id:
return item
return None
def update(
self,
item_id: str,
*,
title: str | None = None,
status: TodoStatus | None = None,
notes: str | None = None,
) -> TodoItem:
for index, item in enumerate(self._data.items):
if item.id != item_id:
continue
new_title = title.strip() if title is not None else item.title
if not new_title:
msg = "title must not be empty"
raise ValueError(msg)
new_status = status if status is not None else item.status
new_notes = notes if notes is not None else item.notes
updated = TodoItem(id=item.id, title=new_title, status=new_status, notes=new_notes)
self._data.items[index] = updated
self.mark_touched()
return updated
msg = f"unknown todo id {item_id!r}"
raise KeyError(msg)
+72
View File
@@ -879,6 +879,78 @@ def history_cmd(state: ReplState, n: int | None) -> None:
)
@slash.command("todos")
@click.argument("action", required=False, type=str)
@click.argument("rest", required=False, nargs=-1, type=str)
@click.pass_obj
def todos_cmd( # noqa: C901, PLR0911, PLR0912
state: ReplState, action: str | None, rest: tuple[str, ...]
) -> None:
"""Show or edit the todo/task stack (sub-tasks).
``/todos`` — list
``/todos push <title>`` — push pending item
``/todos pop`` — remove last open item
``/todos done <id>`` / ``/todos cancel <id>`` — set status
``/todos clear`` — wipe stack
"""
stack = state.todo_stack
act = (action or "list").strip().lower()
if act in {"list", "show", "ls"}:
click.echo(stack.render())
return
if act == "push":
title = " ".join(rest).strip()
if not title:
click.echo("error: usage: /todos push <title>")
return
try:
item = stack.push(title)
except ValueError as exc:
click.echo(f"error: {exc}")
return
_await(state.persist_todo_stack())
click.echo(f"pushed {item.id}: {item.title}")
click.echo(stack.render())
return
if act == "pop":
item = stack.pop()
if item is None:
click.echo("todo stack empty")
return
_await(state.persist_todo_stack())
click.echo(f"popped {item.id}: {item.title}")
click.echo(stack.render())
return
if act in {"done", "cancel", "pending", "in_progress"}:
if not rest:
click.echo(f"error: usage: /todos {act} <id>")
return
if act == "done":
new_status = "done"
elif act == "cancel":
new_status = "cancelled"
elif act == "pending":
new_status = "pending"
else:
new_status = "in_progress"
try:
item = stack.update(rest[0], status=new_status)
except (KeyError, ValueError) as exc:
click.echo(f"error: {exc}")
return
_await(state.persist_todo_stack())
click.echo(f"updated {item.id} → {item.status}")
click.echo(stack.render())
return
if act == "clear":
n = stack.clear()
_await(state.persist_todo_stack())
click.echo(f"cleared {n} item(s)")
return
click.echo("error: usage: /todos [list|push|pop|done|cancel|clear] …")
@slash.command("retry")
@click.pass_obj
def retry_cmd(state: ReplState) -> None:
+58 -1
View File
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Literal, cast
from plyngent.agent import ChatAgent, ChatClient, ToolRegistry
from plyngent.agent.loop import DEFAULT_MAX_ROUNDS
from plyngent.agent.todo_stack import TodoStack
from plyngent.cli.models_source import (
DEFAULT_MODELS_CACHE_TTL,
client_supports_models,
@@ -17,7 +18,7 @@ from plyngent.cli.models_source import (
)
from plyngent.memory.database.store import normalize_workspace
from plyngent.runtime import create_client
from plyngent.tools import DEFAULT_TOOLS, set_workspace_root
from plyngent.tools import DEFAULT_TOOLS, set_todo_stack, set_workspace_root
if TYPE_CHECKING:
from collections.abc import Sequence
@@ -56,6 +57,8 @@ class ReplState:
client: ChatClient = field(init=False)
agent: ChatAgent = field(init=False)
session_id: int | None = None
todo_stack: TodoStack = field(default_factory=TodoStack)
_todo_persist_tasks: set[object] = field(default_factory=set, init=False, repr=False)
# Remote GET /models cache (per provider base).
_remote_models: list[str] | None = field(default=None, init=False, repr=False)
_remote_models_fetched_at: float | None = field(default=None, init=False, repr=False)
@@ -68,6 +71,7 @@ class ReplState:
self.workspace = Path(self.workspace).expanduser().resolve()
self.agent = self._make_agent()
self.sync_display_flags()
self._bind_todo_tools()
def sync_display_flags(self) -> None:
from plyngent.cli.display import set_markdown_enabled, set_verbose_tool_results
@@ -109,6 +113,46 @@ class ReplState:
click.secho("yolo=off (once expired)", fg="bright_black", err=True)
def _bind_todo_tools(self) -> None:
"""Point module-level todo tools at this session stack + persist hook."""
import asyncio
def on_change() -> None:
if self.session_id is None:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
task = loop.create_task(
self.memory.update_session_todo_stack(self.session_id, self.todo_stack.to_raw())
)
# Keep a strong ref until done so the task is not GC'd mid-flight.
self._todo_persist_tasks.add(task)
task.add_done_callback(self._todo_persist_tasks.discard)
set_todo_stack(self.todo_stack, on_change=on_change)
async def persist_todo_stack(self) -> None:
"""Write the in-memory todo stack to the active session row."""
if self.session_id is None:
return
_ = await self.memory.update_session_todo_stack(self.session_id, self.todo_stack.to_raw())
async def load_todo_stack(self) -> None:
"""Load todo stack from the active session (empty if none)."""
if self.session_id is None:
self.todo_stack = TodoStack()
self._bind_todo_tools()
if hasattr(self, "agent"):
self.agent.todo_stack = self.todo_stack
return
raw = await self.memory.get_session_todo_stack(self.session_id)
self.todo_stack = TodoStack.from_raw(raw)
self._bind_todo_tools()
if hasattr(self, "agent"):
self.agent.todo_stack = self.todo_stack
def _tool_registry(self) -> ToolRegistry | None:
if not self.tools_enabled:
return None
@@ -142,6 +186,7 @@ class ReplState:
max_tool_result_chars=agent_cfg.max_tool_result_chars,
parallel_tools=agent_cfg.parallel_tools,
max_context_tokens=agent_cfg.max_context_tokens,
todo_stack=self.todo_stack,
)
def rebuild_client(self) -> None:
@@ -156,6 +201,7 @@ class ReplState:
# Restore history without re-marking already-stored messages as dirty.
self.agent.replace_messages(messages, persist_from=persist_from)
self.sync_display_flags()
self._bind_todo_tools()
# Drop remote catalog when provider identity/url changed (not on model-only switch).
if self._remote_models_key is not None and self._remote_models_key != self._models_cache_key():
self.invalidate_remote_models()
@@ -379,7 +425,9 @@ class ReplState:
model=self.model,
)
self.session_id = session.sid
self.todo_stack = TodoStack()
self.agent = self._make_agent()
self._bind_todo_tools()
async def rename_current_session(self, name: str) -> SessionRow:
if self.session_id is None:
@@ -435,6 +483,7 @@ class ReplState:
else:
self.agent = self._make_agent()
await self.agent.load_history()
await self.load_todo_stack()
async def resume_latest_or_new(self, name: str = "chat") -> str:
"""Resume most recently updated session for this workspace, or create one."""
@@ -449,6 +498,7 @@ class ReplState:
else:
self.agent = self._make_agent()
await self.agent.load_history()
await self.load_todo_stack()
_ = await self.memory.touch_session(latest.sid)
return "resume"
@@ -456,6 +506,7 @@ class ReplState:
"""Soft-compact + model-summarize current history into a new workspace session.
Returns ``(old_session_id, new_session_id, summary)``.
Todo stack is carried into the new session.
"""
from plyngent.agent.compact import build_compacted_seed_messages, summarize_messages
@@ -487,6 +538,7 @@ class ReplState:
system_prompt=self.config.agent_config.compact_system_prompt or None,
user_prefix=self.config.agent_config.compact_user_prefix or None,
)
carried_todos = self.todo_stack.to_raw()
session_name = name or f"compact-from-{old_id}"
await self.new_session(name=session_name)
new_id = self.session_id
@@ -508,4 +560,9 @@ class ReplState:
if not self.agent.messages:
msg = f"compact session {new_id} has no messages after seed"
raise RuntimeError(msg)
# Carry open/closed todos so multi-step work survives compact.
self.todo_stack = TodoStack.from_raw(carried_todos)
self._bind_todo_tools()
self.agent.todo_stack = self.todo_stack
await self.persist_todo_stack()
return old_id, new_id, summary
+2
View File
@@ -32,6 +32,8 @@ class Session(PlyngentBase):
# Last selected provider/model for this session (config provider key + model id).
provider_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
model: Mapped[str | None] = mapped_column(String(256), nullable=True)
# Todo/task stack JSON for multi-step sub-tasks (optional).
todo_stack: Mapped[dict[str, object] | None] = mapped_column(JSON(), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+38 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Self
from typing import TYPE_CHECKING, Self, cast
import msgspec
from sqlalchemy import delete, select, text
@@ -68,6 +68,7 @@ class MemoryStore:
_ = await conn.run_sync(PlyngentBase.metadata.create_all)
await conn.run_sync(_migrate_session_workspace)
await conn.run_sync(_migrate_session_llm)
await conn.run_sync(_migrate_session_todo_stack)
async def close(self) -> None:
"""Dispose the underlying engine."""
@@ -208,6 +209,31 @@ class MemoryStore:
await session.refresh(row)
return row
async def get_session_todo_stack(self, sid: int) -> dict[str, object] | None:
"""Return the stored todo stack JSON for *sid*, or None."""
async with self._session_factory() as session:
row = await session.get(Session, sid)
if row is None:
msg = f"session not found: {sid}"
raise ValueError(msg)
raw = row.todo_stack
if raw is None:
return None
return {str(k): v for k, v in cast("dict[object, object]", raw).items()}
async def update_session_todo_stack(self, sid: int, data: dict[str, object] | None) -> Session:
"""Persist todo stack JSON for a session (None clears)."""
async with self._session_factory() as session:
row = await session.get(Session, sid)
if row is None:
msg = f"session not found: {sid}"
raise ValueError(msg)
row.todo_stack = data
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return row
async def rename_session(self, sid: int, name: str) -> Session:
"""Rename a session (max 64 characters, non-empty after strip)."""
cleaned = name.strip()
@@ -308,3 +334,14 @@ def _migrate_session_llm(sync_conn: object) -> None:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN provider_name VARCHAR(128)"))
if "model" not in columns:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN model VARCHAR(256)"))
def _migrate_session_todo_stack(sync_conn: object) -> None:
"""Add ``session.todo_stack`` JSON for the todo/task sub-task stack."""
from sqlalchemy.engine import Connection
if not isinstance(sync_conn, Connection):
return
columns = _session_columns(sync_conn)
if "todo_stack" not in columns:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN todo_stack JSON"))
+9 -1
View File
@@ -21,6 +21,14 @@ from .process import open_pty as open_pty
from .process import read_pty as read_pty
from .process import run_command as run_command
from .process import write_pty as write_pty
from .todo import TODO_TOOLS as TODO_TOOLS
from .todo import get_todo_stack as get_todo_stack
from .todo import set_todo_stack as set_todo_stack
from .todo import todo_clear as todo_clear
from .todo import todo_list as todo_list
from .todo import todo_pop as todo_pop
from .todo import todo_push as todo_push
from .todo import todo_update as todo_update
from .vcs import VCS_TOOLS as VCS_TOOLS
from .vcs import vcs_branch as vcs_branch
from .vcs import vcs_diff as vcs_diff
@@ -41,4 +49,4 @@ from .workspace import set_command_denylist as set_command_denylist
from .workspace import set_path_denylist as set_path_denylist
from .workspace import set_workspace_root as set_workspace_root
DEFAULT_TOOLS = [*FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS]
DEFAULT_TOOLS = [*FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS, *TODO_TOOLS]
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from plyngent.agent import tool
if TYPE_CHECKING:
from collections.abc import Callable
from plyngent.agent.todo_stack import TodoStack, TodoStatus
# Module-level bind (same pattern as workspace root) for @tool handlers.
_stack: TodoStack | None = None
_on_change: Callable[[], None] | None = None
_VALID_STATUS = frozenset({"pending", "in_progress", "done", "cancelled"})
def set_todo_stack(stack: TodoStack | None, *, on_change: Callable[[], None] | None = None) -> None:
"""Bind the session todo stack for tool handlers (and optional persist hook)."""
global _stack, _on_change # noqa: PLW0603 — intentional process bind
_stack = stack
_on_change = on_change
def get_todo_stack() -> TodoStack | None:
return _stack
def _require_stack() -> TodoStack:
if _stack is None:
msg = "todo stack is not available in this session"
raise RuntimeError(msg)
return _stack
def _notify() -> None:
if _on_change is not None:
_on_change()
@tool(name="todo_list")
def todo_list() -> str:
"""List the current todo/task stack (sub-tasks for multi-step work)."""
stack = _require_stack()
stack.mark_touched()
_notify()
return stack.render()
@tool(name="todo_push")
def todo_push(title: str, notes: str = "") -> str:
"""Push a new sub-task onto the todo stack (pending)."""
stack = _require_stack()
try:
item = stack.push(title, notes=notes)
except ValueError as exc:
return f"error: {exc}"
_notify()
return f"pushed {item.id}: {item.title}\n{stack.render()}"
@tool(name="todo_pop")
def todo_pop() -> str:
"""Pop the last open sub-task (or last item if all closed)."""
stack = _require_stack()
item = stack.pop()
if item is None:
return "todo stack empty"
_notify()
return f"popped {item.id}: {item.title} ({item.status})\n{stack.render()}"
@tool(name="todo_update")
def todo_update(
item_id: str,
status: str = "",
title: str = "",
notes: str = "",
) -> str:
"""Update a todo by id. ``status``: pending|in_progress|done|cancelled."""
stack = _require_stack()
status_arg: TodoStatus | None = None
if status.strip():
token = status.strip().lower()
if token not in _VALID_STATUS:
return "error: status must be pending, in_progress, done, or cancelled"
status_arg = cast("TodoStatus", token)
try:
item = stack.update(
item_id.strip(),
title=title if title.strip() else None,
status=status_arg,
notes=notes if notes != "" else None,
)
except (KeyError, ValueError) as exc:
return f"error: {exc}"
_notify()
return f"updated {item.id} → {item.status}: {item.title}\n{stack.render()}"
@tool(name="todo_clear")
def todo_clear() -> str:
"""Clear the entire todo stack."""
stack = _require_stack()
n = stack.clear()
_notify()
return f"cleared {n} item(s)"
TODO_TOOLS = [todo_list, todo_push, todo_pop, todo_update, todo_clear]