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
+3 -2
View File
@@ -81,7 +81,8 @@ 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`. 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).
- **`DEFAULT_TOOLS`**: file + process + vcs + chat tool list for a `ToolRegistry`.
- **`todo`**: session sub-task stack — `todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`; stored on session row; agent injects a review user message if open todos and no todo tool use in the turn.
- **`DEFAULT_TOOLS`**: file + process + vcs + chat + todo tool list for a `ToolRegistry`.
### Prompting (`prompting.py`)
@@ -92,7 +93,7 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data if unset/`:memory:`); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--stream/--no-stream`, `--quiet`. Root `--log-level`.
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""``"""`; `/edit` via `$EDITOR`. `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML.
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""``"""`; `/edit` via `$EDITOR`. `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack.
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**.
- Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then `/retry`.
- **`plyngent providers`**, **`config path|edit`**. No providers + `$EDITOR` → optional edit then reload.
+2 -1
View File
@@ -201,6 +201,7 @@ Type `/help` in the REPL for the live list. Common ones:
| `/model --persist` | Save current model id into `plyngent.toml` catalog |
| `/models` | List config + remote `GET /models` (always re-fetches) |
| `/models --persist` | Merge remote catalog into TOML for this provider |
| `/todos` | Todo/task stack: list, push, pop, done, clear |
| `/config` | Edit `plyngent.toml` in `$EDITOR` and reload |
| `/quit` | Leave the REPL |
@@ -214,7 +215,7 @@ User messages are saved immediately. On API error or Ctrl+C, partial assistant/t
## Tools (when enabled)
Default registry: file ops (including `tree` with default noise-dir skips), `run_command` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), and human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`).
Default registry: file ops (including `tree` with default noise-dir skips), `run_command` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
Safety defaults:
+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]
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, overload
import pytest
from plyngent.agent import ChatAgent, ToolRegistry
from plyngent.agent.todo_stack import TodoStack
from plyngent.config.models import DatabaseConfig
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
ChatCompletionChoice,
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
UserChatMessage,
)
from plyngent.memory import MemoryStore
from plyngent.tools.todo import TODO_TOOLS, set_todo_stack
if TYPE_CHECKING:
from collections.abc import AsyncIterator
def test_todo_stack_push_pop_update() -> None:
stack = TodoStack()
a = stack.push("one")
b = stack.push("two")
assert a.id == "t1"
assert b.id == "t2"
assert "one" in stack.render()
stack.update("t1", status="done")
popped = stack.pop()
assert popped is not None
assert popped.id == "t2"
assert stack.open_items() == []
def test_todo_stack_needs_review() -> None:
stack = TodoStack()
assert not stack.needs_review()
stack.push("work")
stack.begin_turn()
assert stack.needs_review()
stack.mark_touched()
assert not stack.needs_review()
def test_todo_stack_roundtrip_raw() -> None:
stack = TodoStack()
_ = stack.push("x", notes="n")
raw = stack.to_raw()
restored = TodoStack.from_raw(raw)
assert len(restored.items) == 1
assert restored.items[0].title == "x"
assert restored.items[0].notes == "n"
async def test_todo_tools_and_persist(tmp_path: object) -> None:
del tmp_path
memory = await MemoryStore.open(DatabaseConfig())
try:
session = await memory.create_session(name="t")
stack = TodoStack()
set_todo_stack(stack, on_change=None)
registry = ToolRegistry(list(TODO_TOOLS))
assert "pushed t1" in await registry.execute("todo_push", '{"title": "ship it"}')
assert "t1" in await registry.execute("todo_list", "{}")
assert "done" in await registry.execute(
"todo_update", '{"item_id": "t1", "status": "done"}'
)
_ = await memory.update_session_todo_stack(session.sid, stack.to_raw())
loaded = await memory.get_session_todo_stack(session.sid)
assert loaded is not None
again = TodoStack.from_raw(loaded)
assert again.items[0].status == "done"
finally:
set_todo_stack(None)
await memory.close()
class ScriptedClient:
def __init__(self) -> None:
self.calls = 0
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
) -> ChatCompletionResponse: ...
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[True]
) -> AsyncIterator[ChatCompletionChunk]: ...
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
del stream
self.calls += 1
# First call: finish without tools; second: after review inject.
text = "ok" if self.calls > 1 else "done without todos"
# Detect review message in history
for msg in param.messages:
if isinstance(msg, UserChatMessage) and "todo stack review" in msg.content:
text = "reviewed stack"
break
return ChatCompletionResponse(
id="1",
object="chat.completion",
created=0,
model="m",
choices=[
ChatCompletionChoice(
index=0,
message=AssistantChatMessage(content=text),
logprobs={},
finish_reason="stop",
)
],
system_fingerprint="",
usage={},
)
@pytest.mark.asyncio
async def test_loop_injects_todo_review_when_untouched() -> None:
stack = TodoStack()
_ = stack.push("open work")
stack.begin_turn()
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, UserChatMessage) and "todo stack review" in m.content for m in agent.messages
)
finally:
set_todo_stack(None)