mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user