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).
|
- 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.
|
- **`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).
|
- **`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`)
|
### 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`.
|
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`.
|
- **`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**.
|
- 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`.
|
- 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.
|
- **`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 |
|
| `/model --persist` | Save current model id into `plyngent.toml` catalog |
|
||||||
| `/models` | List config + remote `GET /models` (always re-fetches) |
|
| `/models` | List config + remote `GET /models` (always re-fetches) |
|
||||||
| `/models --persist` | Merge remote catalog into TOML for this provider |
|
| `/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 |
|
| `/config` | Edit `plyngent.toml` in `$EDITOR` and reload |
|
||||||
| `/quit` | Leave the REPL |
|
| `/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)
|
## 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:
|
Safety defaults:
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from .client import ChatClient
|
from .client import ChatClient
|
||||||
from .events import AgentEvent
|
from .events import AgentEvent
|
||||||
|
from .todo_stack import TodoStack
|
||||||
from .tools import ToolRegistry
|
from .tools import ToolRegistry
|
||||||
|
|
||||||
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
|
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
|
||||||
@@ -118,6 +119,7 @@ class ChatAgent:
|
|||||||
max_tool_result_chars: int
|
max_tool_result_chars: int
|
||||||
parallel_tools: bool
|
parallel_tools: bool
|
||||||
max_context_tokens: int
|
max_context_tokens: int
|
||||||
|
todo_stack: TodoStack | None
|
||||||
messages: list[AnyChatMessage]
|
messages: list[AnyChatMessage]
|
||||||
session_usage: TokenUsage
|
session_usage: TokenUsage
|
||||||
last_turn_usage: TokenUsage
|
last_turn_usage: TokenUsage
|
||||||
@@ -143,6 +145,7 @@ class ChatAgent:
|
|||||||
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||||
parallel_tools: bool = True,
|
parallel_tools: bool = True,
|
||||||
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
|
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
|
||||||
|
todo_stack: TodoStack | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.client = client
|
self.client = client
|
||||||
self.model = model
|
self.model = model
|
||||||
@@ -157,6 +160,7 @@ class ChatAgent:
|
|||||||
self.max_tool_result_chars = max_tool_result_chars
|
self.max_tool_result_chars = max_tool_result_chars
|
||||||
self.parallel_tools = parallel_tools
|
self.parallel_tools = parallel_tools
|
||||||
self.max_context_tokens = max_context_tokens
|
self.max_context_tokens = max_context_tokens
|
||||||
|
self.todo_stack = todo_stack
|
||||||
self.messages = list(messages) if messages is not None else []
|
self.messages = list(messages) if messages is not None else []
|
||||||
self.session_usage = TokenUsage()
|
self.session_usage = TokenUsage()
|
||||||
self.last_turn_usage = TokenUsage()
|
self.last_turn_usage = TokenUsage()
|
||||||
@@ -289,6 +293,8 @@ class ChatAgent:
|
|||||||
side-effecting tools. Unfinished assistant/stream suffix is rolled back.
|
side-effecting tools. Unfinished assistant/stream suffix is rolled back.
|
||||||
"""
|
"""
|
||||||
user_index = self._user_index(user_msg)
|
user_index = self._user_index(user_msg)
|
||||||
|
if self.todo_stack is not None:
|
||||||
|
self.todo_stack.begin_turn()
|
||||||
|
|
||||||
completed = False
|
completed = False
|
||||||
turn_usage = TokenUsage()
|
turn_usage = TokenUsage()
|
||||||
@@ -307,6 +313,7 @@ class ChatAgent:
|
|||||||
max_tool_result_chars=self.max_tool_result_chars,
|
max_tool_result_chars=self.max_tool_result_chars,
|
||||||
parallel_tools=self.parallel_tools,
|
parallel_tools=self.parallel_tools,
|
||||||
max_context_tokens=self.max_context_tokens,
|
max_context_tokens=self.max_context_tokens,
|
||||||
|
todo_stack=self.todo_stack,
|
||||||
):
|
):
|
||||||
if isinstance(event, UsageEvent):
|
if isinstance(event, UsageEvent):
|
||||||
turn_rounds += 1
|
turn_rounds += 1
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from plyngent.lmproto.openai_compatible.model import (
|
|||||||
StreamOptions,
|
StreamOptions,
|
||||||
StreamToolCallDelta,
|
StreamToolCallDelta,
|
||||||
ToolChatMessage,
|
ToolChatMessage,
|
||||||
|
UserChatMessage,
|
||||||
)
|
)
|
||||||
from plyngent.typedef import Unset # noqa: TC001
|
from plyngent.typedef import Unset # noqa: TC001
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ if TYPE_CHECKING:
|
|||||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
|
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
|
||||||
|
|
||||||
from .client import ChatClient
|
from .client import ChatClient
|
||||||
|
from .todo_stack import TodoStack
|
||||||
from .tools import ToolRegistry
|
from .tools import ToolRegistry
|
||||||
|
|
||||||
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
|
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,
|
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||||
parallel_tools: bool = True,
|
parallel_tools: bool = True,
|
||||||
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
|
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
|
||||||
|
todo_stack: TodoStack | None = None,
|
||||||
) -> AsyncIterator[AgentEvent]:
|
) -> AsyncIterator[AgentEvent]:
|
||||||
"""Multi-round chat/tool loop; mutates ``messages`` in place and yields events.
|
"""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.
|
text deltas as chunks arrive; tool calls are merged from stream deltas.
|
||||||
Multiple tool calls in one round run in parallel when ``parallel_tools``.
|
Multiple tool calls in one round run in parallel when ``parallel_tools``.
|
||||||
Request payloads may shrink older tool results when over ``max_context_tokens``.
|
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
|
tool_items: Sequence[AnyToolItem] | None = None
|
||||||
if tools is not None and len(tools) > 0:
|
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).
|
# Calibrate soft-compact from last model call's prompt_tokens (API preferred).
|
||||||
prompt_tokens_hint: int | None = None
|
prompt_tokens_hint: int | None = None
|
||||||
sent_estimate_tokens: int | None = None
|
sent_estimate_tokens: int | None = None
|
||||||
|
todo_review_injected = False
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
while rounds_used < allowance:
|
while rounds_used < allowance:
|
||||||
@@ -363,6 +371,14 @@ async def run_chat_loop(
|
|||||||
assistant = _last_assistant(messages, pre_len)
|
assistant = _last_assistant(messages, pre_len)
|
||||||
tool_calls = assistant.tool_calls
|
tool_calls = assistant.tool_calls
|
||||||
if tool_calls is UNSET or not tool_calls or tools is None:
|
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
|
return
|
||||||
async for event in _execute_tool_calls(
|
async for event in _execute_tool_calls(
|
||||||
tools,
|
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")
|
@slash.command("retry")
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def retry_cmd(state: ReplState) -> None:
|
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 import ChatAgent, ChatClient, ToolRegistry
|
||||||
from plyngent.agent.loop import DEFAULT_MAX_ROUNDS
|
from plyngent.agent.loop import DEFAULT_MAX_ROUNDS
|
||||||
|
from plyngent.agent.todo_stack import TodoStack
|
||||||
from plyngent.cli.models_source import (
|
from plyngent.cli.models_source import (
|
||||||
DEFAULT_MODELS_CACHE_TTL,
|
DEFAULT_MODELS_CACHE_TTL,
|
||||||
client_supports_models,
|
client_supports_models,
|
||||||
@@ -17,7 +18,7 @@ from plyngent.cli.models_source import (
|
|||||||
)
|
)
|
||||||
from plyngent.memory.database.store import normalize_workspace
|
from plyngent.memory.database.store import normalize_workspace
|
||||||
from plyngent.runtime import create_client
|
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:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
@@ -56,6 +57,8 @@ class ReplState:
|
|||||||
client: ChatClient = field(init=False)
|
client: ChatClient = field(init=False)
|
||||||
agent: ChatAgent = field(init=False)
|
agent: ChatAgent = field(init=False)
|
||||||
session_id: int | None = None
|
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 GET /models cache (per provider base).
|
||||||
_remote_models: list[str] | None = field(default=None, init=False, repr=False)
|
_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)
|
_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.workspace = Path(self.workspace).expanduser().resolve()
|
||||||
self.agent = self._make_agent()
|
self.agent = self._make_agent()
|
||||||
self.sync_display_flags()
|
self.sync_display_flags()
|
||||||
|
self._bind_todo_tools()
|
||||||
|
|
||||||
def sync_display_flags(self) -> None:
|
def sync_display_flags(self) -> None:
|
||||||
from plyngent.cli.display import set_markdown_enabled, set_verbose_tool_results
|
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)
|
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:
|
def _tool_registry(self) -> ToolRegistry | None:
|
||||||
if not self.tools_enabled:
|
if not self.tools_enabled:
|
||||||
return None
|
return None
|
||||||
@@ -142,6 +186,7 @@ class ReplState:
|
|||||||
max_tool_result_chars=agent_cfg.max_tool_result_chars,
|
max_tool_result_chars=agent_cfg.max_tool_result_chars,
|
||||||
parallel_tools=agent_cfg.parallel_tools,
|
parallel_tools=agent_cfg.parallel_tools,
|
||||||
max_context_tokens=agent_cfg.max_context_tokens,
|
max_context_tokens=agent_cfg.max_context_tokens,
|
||||||
|
todo_stack=self.todo_stack,
|
||||||
)
|
)
|
||||||
|
|
||||||
def rebuild_client(self) -> None:
|
def rebuild_client(self) -> None:
|
||||||
@@ -156,6 +201,7 @@ class ReplState:
|
|||||||
# Restore history without re-marking already-stored messages as dirty.
|
# Restore history without re-marking already-stored messages as dirty.
|
||||||
self.agent.replace_messages(messages, persist_from=persist_from)
|
self.agent.replace_messages(messages, persist_from=persist_from)
|
||||||
self.sync_display_flags()
|
self.sync_display_flags()
|
||||||
|
self._bind_todo_tools()
|
||||||
# Drop remote catalog when provider identity/url changed (not on model-only switch).
|
# 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():
|
if self._remote_models_key is not None and self._remote_models_key != self._models_cache_key():
|
||||||
self.invalidate_remote_models()
|
self.invalidate_remote_models()
|
||||||
@@ -379,7 +425,9 @@ class ReplState:
|
|||||||
model=self.model,
|
model=self.model,
|
||||||
)
|
)
|
||||||
self.session_id = session.sid
|
self.session_id = session.sid
|
||||||
|
self.todo_stack = TodoStack()
|
||||||
self.agent = self._make_agent()
|
self.agent = self._make_agent()
|
||||||
|
self._bind_todo_tools()
|
||||||
|
|
||||||
async def rename_current_session(self, name: str) -> SessionRow:
|
async def rename_current_session(self, name: str) -> SessionRow:
|
||||||
if self.session_id is None:
|
if self.session_id is None:
|
||||||
@@ -435,6 +483,7 @@ class ReplState:
|
|||||||
else:
|
else:
|
||||||
self.agent = self._make_agent()
|
self.agent = self._make_agent()
|
||||||
await self.agent.load_history()
|
await self.agent.load_history()
|
||||||
|
await self.load_todo_stack()
|
||||||
|
|
||||||
async def resume_latest_or_new(self, name: str = "chat") -> str:
|
async def resume_latest_or_new(self, name: str = "chat") -> str:
|
||||||
"""Resume most recently updated session for this workspace, or create one."""
|
"""Resume most recently updated session for this workspace, or create one."""
|
||||||
@@ -449,6 +498,7 @@ class ReplState:
|
|||||||
else:
|
else:
|
||||||
self.agent = self._make_agent()
|
self.agent = self._make_agent()
|
||||||
await self.agent.load_history()
|
await self.agent.load_history()
|
||||||
|
await self.load_todo_stack()
|
||||||
_ = await self.memory.touch_session(latest.sid)
|
_ = await self.memory.touch_session(latest.sid)
|
||||||
return "resume"
|
return "resume"
|
||||||
|
|
||||||
@@ -456,6 +506,7 @@ class ReplState:
|
|||||||
"""Soft-compact + model-summarize current history into a new workspace session.
|
"""Soft-compact + model-summarize current history into a new workspace session.
|
||||||
|
|
||||||
Returns ``(old_session_id, new_session_id, summary)``.
|
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
|
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,
|
system_prompt=self.config.agent_config.compact_system_prompt or None,
|
||||||
user_prefix=self.config.agent_config.compact_user_prefix 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}"
|
session_name = name or f"compact-from-{old_id}"
|
||||||
await self.new_session(name=session_name)
|
await self.new_session(name=session_name)
|
||||||
new_id = self.session_id
|
new_id = self.session_id
|
||||||
@@ -508,4 +560,9 @@ class ReplState:
|
|||||||
if not self.agent.messages:
|
if not self.agent.messages:
|
||||||
msg = f"compact session {new_id} has no messages after seed"
|
msg = f"compact session {new_id} has no messages after seed"
|
||||||
raise RuntimeError(msg)
|
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
|
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).
|
# Last selected provider/model for this session (config provider key + model id).
|
||||||
provider_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
provider_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
model: Mapped[str | None] = mapped_column(String(256), 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())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Self
|
from typing import TYPE_CHECKING, Self, cast
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
from sqlalchemy import delete, select, text
|
from sqlalchemy import delete, select, text
|
||||||
@@ -68,6 +68,7 @@ class MemoryStore:
|
|||||||
_ = await conn.run_sync(PlyngentBase.metadata.create_all)
|
_ = await conn.run_sync(PlyngentBase.metadata.create_all)
|
||||||
await conn.run_sync(_migrate_session_workspace)
|
await conn.run_sync(_migrate_session_workspace)
|
||||||
await conn.run_sync(_migrate_session_llm)
|
await conn.run_sync(_migrate_session_llm)
|
||||||
|
await conn.run_sync(_migrate_session_todo_stack)
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Dispose the underlying engine."""
|
"""Dispose the underlying engine."""
|
||||||
@@ -208,6 +209,31 @@ class MemoryStore:
|
|||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return 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:
|
async def rename_session(self, sid: int, name: str) -> Session:
|
||||||
"""Rename a session (max 64 characters, non-empty after strip)."""
|
"""Rename a session (max 64 characters, non-empty after strip)."""
|
||||||
cleaned = name.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)"))
|
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN provider_name VARCHAR(128)"))
|
||||||
if "model" not in columns:
|
if "model" not in columns:
|
||||||
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN model VARCHAR(256)"))
|
_ = 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 read_pty as read_pty
|
||||||
from .process import run_command as run_command
|
from .process import run_command as run_command
|
||||||
from .process import write_pty as write_pty
|
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_TOOLS as VCS_TOOLS
|
||||||
from .vcs import vcs_branch as vcs_branch
|
from .vcs import vcs_branch as vcs_branch
|
||||||
from .vcs import vcs_diff as vcs_diff
|
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_path_denylist as set_path_denylist
|
||||||
from .workspace import set_workspace_root as set_workspace_root
|
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