diff --git a/CLAUDE.md b/CLAUDE.md index 7fd2843..faff39d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 8575d37..0afcf75 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/plyngent/agent/chat.py b/src/plyngent/agent/chat.py index 1c40dd5..0f13aa6 100644 --- a/src/plyngent/agent/chat.py +++ b/src/plyngent/agent/chat.py @@ -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 diff --git a/src/plyngent/agent/loop.py b/src/plyngent/agent/loop.py index cd4d88f..ec917b9 100644 --- a/src/plyngent/agent/loop.py +++ b/src/plyngent/agent/loop.py @@ -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, diff --git a/src/plyngent/agent/todo_stack.py b/src/plyngent/agent/todo_stack.py new file mode 100644 index 0000000..5c49d44 --- /dev/null +++ b/src/plyngent/agent/todo_stack.py @@ -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) diff --git a/src/plyngent/cli/slash.py b/src/plyngent/cli/slash.py index 29a40a3..4f49cac 100644 --- a/src/plyngent/cli/slash.py +++ b/src/plyngent/cli/slash.py @@ -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 `` — 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: diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index fb91449..43c4030 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -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 diff --git a/src/plyngent/memory/database/schema.py b/src/plyngent/memory/database/schema.py index 867f64d..50547f4 100644 --- a/src/plyngent/memory/database/schema.py +++ b/src/plyngent/memory/database/schema.py @@ -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() diff --git a/src/plyngent/memory/database/store.py b/src/plyngent/memory/database/store.py index e70f3db..a7bac5b 100644 --- a/src/plyngent/memory/database/store.py +++ b/src/plyngent/memory/database/store.py @@ -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")) diff --git a/src/plyngent/tools/__init__.py b/src/plyngent/tools/__init__.py index e2aa259..eee6f40 100644 --- a/src/plyngent/tools/__init__.py +++ b/src/plyngent/tools/__init__.py @@ -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] diff --git a/src/plyngent/tools/todo.py b/src/plyngent/tools/todo.py new file mode 100644 index 0000000..1d637ca --- /dev/null +++ b/src/plyngent/tools/todo.py @@ -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] diff --git a/tests/test_agent/test_todo_stack.py b/tests/test_agent/test_todo_stack.py new file mode 100644 index 0000000..0350742 --- /dev/null +++ b/tests/test_agent/test_todo_stack.py @@ -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)