mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 14:14:57 +08:00
Compare commits
27 Commits
v0.1.1
..
d473cd2508
| Author | SHA1 | Date | |
|---|---|---|---|
| d473cd2508 | |||
| badcb3c9f3 | |||
| fc74b271bd | |||
| 1012894f0d | |||
| 020e9215bf | |||
| de79dec96f | |||
| b1a8c23582 | |||
| 9a9c808ea0 | |||
| edd54a427c | |||
| 896dc98d20 | |||
| c8a0aeda8d | |||
| 29230af911 | |||
| 4ff03d8135 | |||
| a7166d863b | |||
| 9b38a3b333 | |||
| 39f9a7f636 | |||
| 7f49c47105 | |||
| c95d1df028 | |||
| fb5b9f187b | |||
| abcb345c0d | |||
| 596af1ea1b | |||
| 57236937eb | |||
| 96cdd98e62 | |||
| 2368e46efd | |||
| 65d248064e | |||
| e04fda4bc6 | |||
| a282231082 |
@@ -12,12 +12,20 @@ Plyngent is an LLM chat and agent toolkit (Python 3.14+, PDM-managed). Single-us
|
||||
pdm install # first-time dependency setup
|
||||
pdm sync # sync after pulling changes
|
||||
|
||||
pdm run basedpyright . # type checking (basedpyright, "recommended" strictness)
|
||||
pdm run ruff check . # linting
|
||||
pdm run ruff format . # formatting
|
||||
pdm run ruff format . # apply formatting
|
||||
pdm run ruff format --check . # CI: fail if unformatted (do not skip)
|
||||
pdm run basedpyright . # type checking (basedpyright, "recommended" strictness)
|
||||
pdm run pytest # tests (pytest-asyncio auto mode)
|
||||
|
||||
# Commit gateway (prek — https://prek.j178.dev/): ruff check/format + basedpyright
|
||||
uv tool install prek # once
|
||||
prek install # wire git pre-commit hook (once per clone)
|
||||
prek run --all-files # run all hooks on demand
|
||||
```
|
||||
|
||||
GitHub Actions runs `ruff check`, `ruff format --check`, `basedpyright`, then `pytest`. Local commits run the same checks via `prek.toml` so `ruff format` is not forgotten.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data modeling: `msgspec.Struct`
|
||||
@@ -70,15 +78,16 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
||||
|
||||
Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
||||
|
||||
- **`workspace`**: path resolve under root; path substring denylist; command basename denylist; clearer deny messages.
|
||||
- **`file`**: `read_file`, `write_file`, `listdir`, `tree`, `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace`, `edit_lineno` (1-based range), `copy_path` / `move_path` / `delete_path`.
|
||||
- **`process`**: `run_command` (argv, no shell, timeout, optional stdin/env); PTY `open_pty` / `read_pty` / `write_pty` / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
|
||||
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)`; session limit/idle TTL/output budget; close terminate→kill.
|
||||
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots; path substring denylist; command basename denylist; clearer deny messages.
|
||||
- **`file`**: `read_file` (`with_lineno`), `write_file`, `listdir`, `tree` (VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace`, `edit_lineno` (1-based range), `copy_path` / `move_path` / `delete_path`, `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind).
|
||||
- **`process`**: `run_command` (argv, no shell, timeout, optional stdin/env); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (escapes) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
|
||||
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`\xHH`, `ctrl+x`, `key=esc`); session limit/idle TTL/output budget; close terminate→kill.
|
||||
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget.
|
||||
- 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`. Soft confirm also for shells/REPLs and `python|bash -c` one-liners (argv + code preview); deny may include a user comment for the model. 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`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by id; DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; agent injects a developer review 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`)
|
||||
|
||||
@@ -89,7 +98,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.
|
||||
- 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.
|
||||
|
||||
@@ -62,15 +62,25 @@ pdm sync # after pull
|
||||
pdm run plyngent --help
|
||||
```
|
||||
|
||||
Dev checks:
|
||||
Dev checks (same order as CI):
|
||||
|
||||
```bash
|
||||
pdm run basedpyright .
|
||||
pdm run ruff check .
|
||||
pdm run ruff format .
|
||||
pdm run ruff format --check . # or: pdm run ruff format . to apply
|
||||
pdm run basedpyright .
|
||||
pdm run pytest
|
||||
```
|
||||
|
||||
**Commit gateway** ([prek](https://prek.j178.dev/)): runs ruff check + format and basedpyright on `git commit` so format is not forgotten.
|
||||
|
||||
```bash
|
||||
uv tool install prek # once
|
||||
prek install # once per clone (installs .git/hooks/pre-commit)
|
||||
prek run --all-files # run all hooks on demand
|
||||
```
|
||||
|
||||
Config: `prek.toml`. CI still runs the same checks in GitHub Actions.
|
||||
|
||||
## Basic usage
|
||||
|
||||
```bash
|
||||
@@ -186,7 +196,8 @@ Type `/help` in the REPL for the live list. Common ones:
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `/status` | Provider, session, context/usage estimates |
|
||||
| `/history [n]` | Recent messages |
|
||||
| `/history [n\|last]` | Recent messages (default preview; `last`/`1` = full + markdown) |
|
||||
| `/history --full` | Full bodies for the selected window |
|
||||
| `/sessions` | Sessions for this workspace |
|
||||
| `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) |
|
||||
| `/export [md\|json] [path]` | Transcript from DB (no secrets) |
|
||||
@@ -196,7 +207,10 @@ Type `/help` in the REPL for the live list. Common ones:
|
||||
|
||||
| `/retry` | Re-run incomplete last user turn (after error/cancel) |
|
||||
| `/provider` `/model` | Switch without restarting |
|
||||
| `/models` | List config + remote `GET /models` (`--refresh` bypasses cache) |
|
||||
| `/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 |
|
||||
|
||||
@@ -210,14 +224,17 @@ User messages are saved immediately. On API error or Ctrl+C, partial assistant/t
|
||||
|
||||
## Tools (when enabled)
|
||||
|
||||
Default registry: file ops, `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:
|
||||
|
||||
- Paths stay under the workspace; optional `path_denylist` substrings.
|
||||
- Paths stay under the workspace; optional `path_denylist` substrings (`tree` also skips denylisted children by default).
|
||||
- Command basename denylist (e.g. dangerous shells/utilities).
|
||||
- Destructive tools (delete/move/overwrite) can require confirm (`confirm_destructive`; default deny in non-TTY). Override for the session with `/yolo on|off|once` or startup `--yes` (path/command denylists still apply).
|
||||
- PTY sessions: caps, idle TTL, output budget; master FD is non-inheritable; sessions closed on chat exit.
|
||||
Prefer file tools over full-screen editors (`vim`/`nano`) for edits. `read_pty` sanitizes CSI/controls
|
||||
so tool results cannot reprogram the host TTY (no host terminal reset on exit).
|
||||
`write_pty` is literal text only; use `write_pty_keys` for `\xHH`, `ctrl+x`, `key=esc|enter|…`.
|
||||
|
||||
## Usage / context (CLI)
|
||||
|
||||
|
||||
@@ -45,7 +45,10 @@ Then for every projects, check the dev dependency group where you can find tools
|
||||
|
||||
Type checking: `pdm run basedpyright .`
|
||||
Linting: `pdm run ruff check .`
|
||||
Formating: `pdm run ruff format .`
|
||||
Formatting (apply): `pdm run ruff format .`
|
||||
Formatting (CI check): `pdm run ruff format --check .`
|
||||
|
||||
CI (`.github/workflows/ci.yml`) runs lint, **format check**, typecheck, then tests. Always run `pdm run ruff format .` before push so `ruff format --check` does not fail on `main` after a release tag has already published.
|
||||
|
||||
<!-- Contents in comments are for projects with long-period developments, but this is a new project.
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Commit gateway via prek (https://prek.j178.dev/) — drop-in pre-commit compatible.
|
||||
# Install: uv tool install prek (or: pipx install prek)
|
||||
# Wire git: prek install
|
||||
# Manual: prek run --all-files
|
||||
|
||||
default_install_hook_types = ["pre-commit"]
|
||||
default_stages = ["pre-commit"]
|
||||
default_language_version = { python = "python3.14" }
|
||||
|
||||
# Ruff: lint (with autofix) then format — order matters (fix before format).
|
||||
# https://docs.astral.sh/ruff/integrations/#pre-commit
|
||||
[[repos]]
|
||||
repo = "https://github.com/astral-sh/ruff-pre-commit"
|
||||
rev = "v0.15.22"
|
||||
hooks = [
|
||||
{ id = "ruff-check", args = ["--fix"], types_or = ["python", "pyi"] },
|
||||
{ id = "ruff-format", types_or = ["python", "pyi"] },
|
||||
]
|
||||
|
||||
# basedpyright: full-project typecheck (same as CI `pdm run basedpyright .`).
|
||||
# Mirror hook: https://docs.basedpyright.com/latest/installation/prek-hook/
|
||||
# Uses system+PDM so imports resolve from the project venv (isolated hook envs lack deps).
|
||||
[[repos]]
|
||||
repo = "local"
|
||||
hooks = [
|
||||
{ id = "basedpyright", name = "basedpyright", language = "system", entry = "pdm run basedpyright .", pass_filenames = false, files = "\\.pyi?$" },
|
||||
]
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "plyngent"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
description = "LLM chat and agent toolkit"
|
||||
authors = [
|
||||
{ name = "HivertMoZara", email = "worldmozara@163.com" },
|
||||
|
||||
@@ -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()
|
||||
@@ -200,14 +204,38 @@ class ChatAgent:
|
||||
if self._persist_from == 0:
|
||||
self._persist_from = 1
|
||||
|
||||
def replace_messages(
|
||||
self,
|
||||
messages: Sequence[AnyChatMessage],
|
||||
*,
|
||||
persisted: bool = True,
|
||||
persist_from: int | None = None,
|
||||
) -> None:
|
||||
"""Replace in-memory history and align the persistence checkpoint.
|
||||
|
||||
When *persist_from* is set, it is used as the checkpoint cursor
|
||||
(clamped to ``[0, len(messages)]``). Otherwise *persisted* true means
|
||||
all messages are already stored; false means none are.
|
||||
"""
|
||||
self.messages = list(messages)
|
||||
if persist_from is not None:
|
||||
self._persist_from = max(0, min(persist_from, len(self.messages)))
|
||||
else:
|
||||
self._persist_from = len(self.messages) if persisted else 0
|
||||
self._ensure_system_prompt()
|
||||
|
||||
@property
|
||||
def persist_from(self) -> int:
|
||||
"""Index of the first unpersisted message (checkpoint cursor)."""
|
||||
return self._persist_from
|
||||
|
||||
async def load_history(self) -> None:
|
||||
"""Replace in-memory messages from the bound memory session."""
|
||||
if self.memory is None or self.session_id is None:
|
||||
msg = "load_history requires memory and session_id"
|
||||
raise RuntimeError(msg)
|
||||
self.messages = await self.memory.list_messages(self.session_id)
|
||||
self._persist_from = len(self.messages)
|
||||
self._ensure_system_prompt()
|
||||
loaded = await self.memory.list_messages(self.session_id)
|
||||
self.replace_messages(loaded, persisted=True)
|
||||
|
||||
async def bind_session(self, session_id: int, *, load: bool = True) -> None:
|
||||
"""Attach a memory session id; optionally load existing messages."""
|
||||
@@ -265,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()
|
||||
@@ -283,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
|
||||
|
||||
@@ -8,8 +8,8 @@ from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantChatMessage,
|
||||
AssistantFunctionToolCall,
|
||||
ChatCompletionsParam,
|
||||
DeveloperChatMessage,
|
||||
SystemChatMessage,
|
||||
ToolChatMessage,
|
||||
UserChatMessage,
|
||||
)
|
||||
|
||||
@@ -49,6 +49,8 @@ def format_transcript(messages: Sequence[AnyChatMessage]) -> str:
|
||||
for msg in messages:
|
||||
if isinstance(msg, SystemChatMessage):
|
||||
lines.append(f"[system] {msg.content}")
|
||||
elif isinstance(msg, DeveloperChatMessage):
|
||||
lines.append(f"[developer] {msg.content}")
|
||||
elif isinstance(msg, UserChatMessage):
|
||||
lines.append(f"[user] {msg.content}")
|
||||
elif isinstance(msg, AssistantChatMessage):
|
||||
@@ -62,10 +64,9 @@ def format_transcript(messages: Sequence[AnyChatMessage]) -> str:
|
||||
lines.append(f"[assistant tool_call] {call.function.name}({call.function.arguments})")
|
||||
else:
|
||||
lines.append(f"[assistant tool_call] custom id={call.id}")
|
||||
elif isinstance(msg, ToolChatMessage):
|
||||
lines.append(f"[tool {msg.tool_call_id}] {msg.content}")
|
||||
else:
|
||||
lines.append(f"[message] {msg!r}")
|
||||
# ToolChatMessage (remaining AnyChatMessage arm)
|
||||
lines.append(f"[tool {msg.tool_call_id}] {msg.content}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantChatMessage,
|
||||
AssistantFunctionToolCall,
|
||||
ChatCompletionsParam,
|
||||
DeveloperChatMessage,
|
||||
StreamOptions,
|
||||
StreamToolCallDelta,
|
||||
ToolChatMessage,
|
||||
@@ -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]]
|
||||
@@ -114,6 +116,66 @@ async def _execute_tool_calls(
|
||||
yield ToolResultEvent(message=tool_msg)
|
||||
|
||||
|
||||
def _assistant_has_payload(assistant: AssistantChatMessage) -> bool:
|
||||
"""True if the model produced text, reasoning, or tool calls."""
|
||||
if assistant.tool_calls is not UNSET and assistant.tool_calls:
|
||||
return True
|
||||
if isinstance(assistant.content, str) and assistant.content.strip():
|
||||
return True
|
||||
reasoning = assistant.reasoning_content
|
||||
return bool(isinstance(reasoning, str) and reasoning.strip())
|
||||
|
||||
|
||||
def _finish_reason_value(finish: object) -> str | None:
|
||||
if finish is UNSET or finish is None:
|
||||
return None
|
||||
if isinstance(finish, str) and finish.strip():
|
||||
return finish.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _validate_assistant_terminal(
|
||||
assistant: AssistantChatMessage,
|
||||
*,
|
||||
finish_reason: str | None,
|
||||
stream_terminal: bool,
|
||||
) -> None:
|
||||
"""Raise when the round is not a usable agent stop.
|
||||
|
||||
Distinguishes empty generation, truncated/filtered stops, and missing
|
||||
stream terminals (network/client glitch) from a normal stop/tool_calls end.
|
||||
"""
|
||||
reason = (finish_reason or "").lower() or None
|
||||
|
||||
if reason in {"length", "content_filter"}:
|
||||
label = "truncated (max tokens)" if reason == "length" else "content filter"
|
||||
detail = ""
|
||||
if isinstance(assistant.content, str) and assistant.content.strip():
|
||||
detail = f"; partial text kept ({len(assistant.content)} chars)"
|
||||
msg = f"model stopped early: {label}{detail}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
if reason in {"failed", "incomplete", "cancelled"}:
|
||||
msg = f"model response status: {reason}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
if _assistant_has_payload(assistant):
|
||||
return
|
||||
|
||||
if not stream_terminal and reason is None:
|
||||
msg = (
|
||||
"stream ended without a terminal signal (no finish_reason / response.completed) and empty assistant output"
|
||||
)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
if reason in {"stop", "tool_calls", "function_call"} or reason is None:
|
||||
msg = "empty model completion (no text, reasoning, or tool calls)"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
msg = f"empty model completion (finish_reason={reason})"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
async def _non_stream_round(
|
||||
client: ChatClient,
|
||||
param: ChatCompletionsParam,
|
||||
@@ -122,7 +184,14 @@ async def _non_stream_round(
|
||||
if not response.choices:
|
||||
msg = "chat completion response contained no choices"
|
||||
raise RuntimeError(msg)
|
||||
assistant = response.choices[0].message
|
||||
choice = response.choices[0]
|
||||
assistant = choice.message
|
||||
finish = _finish_reason_value(choice.finish_reason)
|
||||
_validate_assistant_terminal(
|
||||
assistant,
|
||||
finish_reason=finish,
|
||||
stream_terminal=True,
|
||||
)
|
||||
reasoning = assistant.reasoning_content
|
||||
if isinstance(reasoning, str) and reasoning:
|
||||
yield ReasoningDeltaEvent(content=reasoning)
|
||||
@@ -154,6 +223,8 @@ async def _stream_round(
|
||||
reasoning_parts: list[str] = []
|
||||
tool_deltas: list[StreamToolCallDelta] = []
|
||||
last_api_usage: object = UNSET
|
||||
finish_reason: str | None = None
|
||||
saw_terminal = False
|
||||
|
||||
async for chunk in stream:
|
||||
parsed = token_usage_from_api(chunk.usage)
|
||||
@@ -162,6 +233,10 @@ async def _stream_round(
|
||||
if not chunk.choices:
|
||||
continue
|
||||
choice = chunk.choices[0]
|
||||
fr = _finish_reason_value(choice.finish_reason)
|
||||
if fr is not None:
|
||||
finish_reason = fr
|
||||
saw_terminal = True
|
||||
delta = choice.delta
|
||||
if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
|
||||
reasoning_parts.append(delta.reasoning_content)
|
||||
@@ -185,6 +260,16 @@ async def _stream_round(
|
||||
tool_calls=tool_calls,
|
||||
reasoning_content=full_reasoning or UNSET,
|
||||
)
|
||||
# Usage-only final chunks leave choices empty; a non-empty usage after
|
||||
# content still is not a finish_reason. Prefer explicit finish_reason.
|
||||
# If we got payload but no finish_reason, treat as terminal (some providers
|
||||
# omit it); if empty and no finish_reason, flag as missing terminal.
|
||||
stream_terminal = saw_terminal or _assistant_has_payload(assistant)
|
||||
_validate_assistant_terminal(
|
||||
assistant,
|
||||
finish_reason=finish_reason,
|
||||
stream_terminal=stream_terminal,
|
||||
)
|
||||
yield AssistantMessageEvent(message=assistant)
|
||||
yield UsageEvent(
|
||||
usage=resolve_round_usage(last_api_usage, param.messages, assistant),
|
||||
@@ -235,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.
|
||||
|
||||
@@ -242,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:
|
||||
@@ -252,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:
|
||||
@@ -280,6 +371,11 @@ 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
|
||||
# Non-user control identity so retry/history don't treat this as human input.
|
||||
messages.append(DeveloperChatMessage(content=todo_stack.review_prompt()))
|
||||
continue
|
||||
return
|
||||
async for event in _execute_tool_calls(
|
||||
tools,
|
||||
|
||||
@@ -29,10 +29,10 @@ from plyngent.lmproto.openai_compatible.model import (
|
||||
ChatCompletionsParam,
|
||||
ChunkChoice,
|
||||
DeltaMessage,
|
||||
DeveloperChatMessage,
|
||||
StreamFunctionDelta,
|
||||
StreamToolCallDelta,
|
||||
SystemChatMessage,
|
||||
ToolChatMessage,
|
||||
ToolFunctionItem,
|
||||
UserChatMessage,
|
||||
)
|
||||
@@ -97,21 +97,22 @@ def chat_messages_to_responses_input(
|
||||
if isinstance(message, SystemChatMessage):
|
||||
if message.content.strip():
|
||||
instructions_parts.append(message.content)
|
||||
elif isinstance(message, DeveloperChatMessage):
|
||||
# Keep mid-turn control as input developer messages (not folded into instructions).
|
||||
if message.content.strip():
|
||||
items.append(ResponseEasyInputMessage(role="developer", content=message.content))
|
||||
elif isinstance(message, UserChatMessage):
|
||||
items.append(ResponseEasyInputMessage(role="user", content=message.content))
|
||||
elif isinstance(message, AssistantChatMessage):
|
||||
items.extend(_assistant_to_input_items(message))
|
||||
elif isinstance(message, ToolChatMessage):
|
||||
else:
|
||||
# ToolChatMessage (remaining AnyChatMessage arm)
|
||||
items.append(
|
||||
ResponseFunctionToolCallOutput(
|
||||
call_id=message.tool_call_id,
|
||||
output=message.content,
|
||||
)
|
||||
)
|
||||
else:
|
||||
content = getattr(message, "content", None)
|
||||
if isinstance(content, str) and content:
|
||||
items.append(ResponseEasyInputMessage(role="user", content=content))
|
||||
|
||||
instructions = "\n\n".join(instructions_parts) if instructions_parts else None
|
||||
return instructions, items
|
||||
@@ -158,10 +159,33 @@ def _reasoning_summary_text(response: Response) -> str:
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def responses_status_to_finish_reason(
|
||||
response: Response,
|
||||
*,
|
||||
has_tool_calls: bool,
|
||||
) -> str:
|
||||
"""Map Responses ``status`` to a chat-style finish_reason for the agent loop."""
|
||||
status = response.status
|
||||
status_s = status if isinstance(status, str) else None
|
||||
if status_s == "incomplete":
|
||||
details = response.incomplete_details
|
||||
if details is not UNSET and details is not None:
|
||||
raw_reason = details.reason
|
||||
if raw_reason is not UNSET and raw_reason == "content_filter":
|
||||
return "content_filter"
|
||||
return "length"
|
||||
if status_s in {"failed", "cancelled"}:
|
||||
return status_s
|
||||
if has_tool_calls:
|
||||
return "tool_calls"
|
||||
return "stop"
|
||||
|
||||
|
||||
def response_to_chat_completion(response: Response) -> ChatCompletionResponse:
|
||||
"""Wrap Responses result as a synthetic chat completion for the agent loop."""
|
||||
assistant = response_to_assistant_message(response)
|
||||
finish: str | None = "tool_calls" if assistant.tool_calls is not UNSET else "stop"
|
||||
has_tools = assistant.tool_calls is not UNSET and bool(assistant.tool_calls)
|
||||
finish = responses_status_to_finish_reason(response, has_tool_calls=has_tools)
|
||||
usage = response.usage if response.usage is not UNSET else UNSET
|
||||
created = int(response.created_at)
|
||||
return ChatCompletionResponse(
|
||||
@@ -180,6 +204,28 @@ def response_to_chat_completion(response: Response) -> ChatCompletionResponse:
|
||||
)
|
||||
|
||||
|
||||
def finish_reason_chunk(
|
||||
*,
|
||||
model: str,
|
||||
finish_reason: str,
|
||||
created: int = 0,
|
||||
) -> ChatCompletionChunk:
|
||||
"""Terminal stream chunk carrying only ``finish_reason`` (no delta text)."""
|
||||
return ChatCompletionChunk(
|
||||
id="resp-stream",
|
||||
object="chat.completion.chunk",
|
||||
created=created,
|
||||
model=model,
|
||||
choices=[
|
||||
ChunkChoice(
|
||||
index=0,
|
||||
delta=DeltaMessage(),
|
||||
finish_reason=cast("Any", finish_reason),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def text_delta_chunk(*, model: str, content: str, created: int = 0) -> ChatCompletionChunk:
|
||||
return ChatCompletionChunk(
|
||||
id="resp-stream",
|
||||
|
||||
@@ -8,8 +8,11 @@ from msgspec import UNSET
|
||||
|
||||
from plyngent.agent.responses_bridge import (
|
||||
chat_param_to_responses_kwargs,
|
||||
finish_reason_chunk,
|
||||
reasoning_delta_chunk,
|
||||
response_to_assistant_message,
|
||||
response_to_chat_completion,
|
||||
responses_status_to_finish_reason,
|
||||
text_delta_chunk,
|
||||
tool_call_chunks_from_response,
|
||||
usage_chunk_from_response,
|
||||
@@ -109,9 +112,17 @@ class ResponsesChatClient:
|
||||
except TypeError, ValueError, msgspec.ValidationError:
|
||||
final = None
|
||||
|
||||
if final is not None:
|
||||
if final is None:
|
||||
# Stream ended without response.completed — signal missing terminal.
|
||||
# Loop will treat empty + no finish_reason as a glitch.
|
||||
return
|
||||
|
||||
assistant = response_to_assistant_message(final)
|
||||
has_tools = assistant.tool_calls is not UNSET and bool(assistant.tool_calls)
|
||||
finish = responses_status_to_finish_reason(final, has_tool_calls=has_tools)
|
||||
for chunk in tool_call_chunks_from_response(final, model=model):
|
||||
yield chunk
|
||||
yield finish_reason_chunk(model=model, finish_reason=finish)
|
||||
usage = usage_chunk_from_response(final, model=model)
|
||||
if usage is not None:
|
||||
yield usage
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
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 task inside a group (siblings share a group; not stacked individually)."""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
status: TodoStatus = "pending"
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class TodoGroup(Struct, omit_defaults=True):
|
||||
"""One stack entry: a group of sibling tasks pushed together."""
|
||||
|
||||
items: list[TodoItem] = field(default_factory=list)
|
||||
|
||||
|
||||
class TodoStackData(Struct, omit_defaults=True):
|
||||
"""LIFO of **groups**: ``groups[0]`` bottom, ``groups[-1]`` **TOP** group."""
|
||||
|
||||
groups: list[TodoGroup] = field(default_factory=list)
|
||||
next_id: int = 1
|
||||
|
||||
|
||||
class TodoStack:
|
||||
"""LIFO stack of **task groups** (not a queue of individual tasks).
|
||||
|
||||
- **Push** always creates **one new group** containing one or more sibling
|
||||
tasks (``push T1, T2`` is one frame, not two stack levels).
|
||||
- **Pop** removes the entire **top group**.
|
||||
- Within a group, update tasks by id (done / in_progress / …).
|
||||
|
||||
Breakdown pattern::
|
||||
|
||||
push [T1, T2] # top group = {T1, T2}
|
||||
push [T1.1, T1.2] # top group = {T1.1, T1.2}; under = {T1, T2}
|
||||
# finish T1.1 / T1.2 via update…
|
||||
pop # leave child group; top again = {T1, T2}
|
||||
push [T2.1] # top group = {T2.1}
|
||||
"""
|
||||
|
||||
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 groups(self) -> list[TodoGroup]:
|
||||
"""Bottom → top (last is top group)."""
|
||||
return self._data.groups
|
||||
|
||||
@property
|
||||
def top_group(self) -> TodoGroup | None:
|
||||
if not self._data.groups:
|
||||
return None
|
||||
return self._data.groups[-1]
|
||||
|
||||
@property
|
||||
def depth(self) -> int:
|
||||
return len(self._data.groups)
|
||||
|
||||
@property
|
||||
def touched_this_turn(self) -> bool:
|
||||
return self._touched_this_turn
|
||||
|
||||
def begin_turn(self) -> None:
|
||||
self._touched_this_turn = False
|
||||
|
||||
def mark_touched(self) -> None:
|
||||
self._touched_this_turn = True
|
||||
|
||||
def all_items(self) -> list[TodoItem]:
|
||||
return [item for group in self._data.groups for item in group.items]
|
||||
|
||||
def open_items(self) -> list[TodoItem]:
|
||||
return [item for item in self.all_items() if item.status in _OPEN]
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return not self._data.groups
|
||||
|
||||
def needs_review(self) -> bool:
|
||||
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: # noqa: C901, PLR0911
|
||||
if raw is None or not isinstance(raw, dict):
|
||||
return cls()
|
||||
blob = cast("dict[str, object]", raw)
|
||||
|
||||
# Current shape: {groups: [...], next_id}
|
||||
if "groups" in blob:
|
||||
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)
|
||||
|
||||
# Intermediate nested frames → groups (same structure).
|
||||
if "frames" in blob:
|
||||
try:
|
||||
frames_raw = blob.get("frames")
|
||||
next_raw = blob.get("next_id", 1)
|
||||
next_id = max(1, int(next_raw) if isinstance(next_raw, int | str) else 1)
|
||||
groups: list[TodoGroup] = []
|
||||
if isinstance(frames_raw, list):
|
||||
for frame in cast("list[object]", frames_raw):
|
||||
if not isinstance(frame, dict):
|
||||
continue
|
||||
frame_map = cast("dict[str, object]", frame)
|
||||
frame_items = msgspec.convert(frame_map.get("items"), type=list[TodoItem])
|
||||
if frame_items:
|
||||
groups.append(TodoGroup(items=frame_items))
|
||||
return cls(TodoStackData(groups=groups, next_id=next_id))
|
||||
except msgspec.ValidationError, TypeError, ValueError:
|
||||
return cls()
|
||||
|
||||
# Flat items list → one group (legacy).
|
||||
if "items" in blob:
|
||||
try:
|
||||
items = msgspec.convert(blob.get("items"), type=list[TodoItem])
|
||||
next_raw = blob.get("next_id", 1)
|
||||
next_id = max(1, int(next_raw) if isinstance(next_raw, int | str) else 1)
|
||||
except msgspec.ValidationError, TypeError, ValueError:
|
||||
return cls()
|
||||
groups = [TodoGroup(items=items)] if items else []
|
||||
return cls(TodoStackData(groups=groups, next_id=next_id))
|
||||
|
||||
return cls()
|
||||
|
||||
def to_raw(self) -> dict[str, object]:
|
||||
out: object = msgspec.to_builtins(self._data)
|
||||
if not isinstance(out, dict):
|
||||
return {"groups": [], "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.groups:
|
||||
return "(todo stack empty — no groups)"
|
||||
lines: list[str] = [
|
||||
f"(LIFO of groups: depth={self.depth}; TOP group = current breakdown level)",
|
||||
]
|
||||
# Top group first for the model.
|
||||
for offset, group in enumerate(reversed(self._data.groups)):
|
||||
depth = self.depth - 1 - offset
|
||||
tag = " TOP" if offset == 0 else ""
|
||||
lines.append(f"group d={depth}{tag}:")
|
||||
if not group.items:
|
||||
lines.append(" (empty group)")
|
||||
continue
|
||||
for item in group.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:
|
||||
"""Compact, model-facing nag: open work still exists this turn."""
|
||||
open_items = self.open_items()
|
||||
n_open = len(open_items)
|
||||
n_groups = self.depth
|
||||
lines = [
|
||||
f"[TODO OPEN] {n_open} open item(s) across {n_groups} group(s) "
|
||||
f"(not empty). You did not call todo_* this turn.",
|
||||
"Tools: todo_list | todo_push(titles=[...]) | todo_update | todo_pop | todo_clear",
|
||||
"Rules: TOP group = current level; push=one sibling group; pop=remove whole TOP group.",
|
||||
"Act on open work before ending (finish/pop TOP, or push child tasks). Stack:",
|
||||
self.render(),
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
def _existing_numeric_ids(self) -> list[int]:
|
||||
"""Numeric suffixes of ids that look like ``tN`` (for counter reuse)."""
|
||||
return [
|
||||
int(item.id[1:])
|
||||
for group in self._data.groups
|
||||
for item in group.items
|
||||
if item.id.startswith("t") and item.id[1:].isdigit()
|
||||
]
|
||||
|
||||
def _sync_next_id(self) -> None:
|
||||
"""Set ``next_id`` to one past the highest live id (or 1 if empty)."""
|
||||
nums = self._existing_numeric_ids()
|
||||
self._data.next_id = max(nums) + 1 if nums else 1
|
||||
|
||||
def _alloc(self, title: str, *, notes: str, status: TodoStatus) -> TodoItem:
|
||||
item_id = f"t{self._data.next_id}"
|
||||
self._data.next_id += 1
|
||||
return TodoItem(id=item_id, title=title, status=status, notes=notes)
|
||||
|
||||
def push_group(
|
||||
self,
|
||||
titles: list[str],
|
||||
*,
|
||||
notes: str = "",
|
||||
status: TodoStatus = "pending",
|
||||
) -> TodoGroup:
|
||||
"""Push **one** new group containing all *titles* as siblings."""
|
||||
cleaned = [t.strip() for t in titles if t and t.strip()]
|
||||
if not cleaned:
|
||||
msg = "at least one non-empty title is required"
|
||||
raise ValueError(msg)
|
||||
note = notes.strip()
|
||||
# Rebase counter on live ids so clear/pop do not leave a high watermark.
|
||||
self._sync_next_id()
|
||||
items = [self._alloc(title, notes=note, status=status) for title in cleaned]
|
||||
group = TodoGroup(items=items)
|
||||
self._data.groups.append(group)
|
||||
self.mark_touched()
|
||||
return group
|
||||
|
||||
def push(self, title: str, *, notes: str = "", status: TodoStatus = "pending") -> TodoItem:
|
||||
"""Push a group with a single task (still one stack level)."""
|
||||
group = self.push_group([title], notes=notes, status=status)
|
||||
return group.items[0]
|
||||
|
||||
def pop(self) -> TodoGroup | None:
|
||||
"""Pop and return the **top group** (all siblings in that push)."""
|
||||
if not self._data.groups:
|
||||
return None
|
||||
group = self._data.groups.pop()
|
||||
self._sync_next_id()
|
||||
self.mark_touched()
|
||||
return group
|
||||
|
||||
def clear(self) -> int:
|
||||
n = sum(len(g.items) for g in self._data.groups)
|
||||
self._data.groups.clear()
|
||||
self._sync_next_id()
|
||||
if n:
|
||||
self.mark_touched()
|
||||
return n
|
||||
|
||||
def get(self, item_id: str) -> TodoItem | None:
|
||||
for item in self.all_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 group in self._data.groups:
|
||||
for index, item in enumerate(group.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)
|
||||
group.items[index] = updated
|
||||
self.mark_touched()
|
||||
return updated
|
||||
msg = f"unknown todo id {item_id!r}"
|
||||
raise KeyError(msg)
|
||||
|
||||
|
||||
def parse_push_titles(raw: str) -> list[str]:
|
||||
"""Parse multi-title push: JSON array, newlines, or ``;``-separated."""
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
return []
|
||||
if text.startswith("["):
|
||||
try:
|
||||
data: object = msgspec.json.decode(text.encode())
|
||||
except msgspec.DecodeError, UnicodeEncodeError:
|
||||
data = None
|
||||
if isinstance(data, list):
|
||||
out = [item.strip() for item in cast("list[object]", data) if isinstance(item, str) and item.strip()]
|
||||
if out:
|
||||
return out
|
||||
parts: list[str] = []
|
||||
for line in text.replace(";", "\n").splitlines():
|
||||
token = line.strip()
|
||||
if token:
|
||||
parts.append(token)
|
||||
return parts
|
||||
@@ -13,9 +13,11 @@ from plyngent.typedef import JSONSchema # noqa: TC001
|
||||
|
||||
type ToolHandler = Callable[..., Any | Awaitable[Any]]
|
||||
type DangerClassifier = Callable[[str, Mapping[str, object]], str | None]
|
||||
# Returns True to allow, False to deny, or a non-empty str as denial reason for the model.
|
||||
type ToolConfirmResult = bool | str
|
||||
type ToolConfirmHook = Callable[
|
||||
[str, Mapping[str, object], str],
|
||||
bool | Awaitable[bool],
|
||||
ToolConfirmResult | Awaitable[ToolConfirmResult],
|
||||
]
|
||||
|
||||
_PRIMITIVE_SCHEMA: dict[type, JSONSchema] = {
|
||||
@@ -212,7 +214,11 @@ class ToolRegistry:
|
||||
return msgspec.json.encode(result).decode()
|
||||
|
||||
async def _maybe_confirm(self, name: str, args: dict[str, object]) -> str | None:
|
||||
"""Return an error string if the user denies a dangerous tool, else None."""
|
||||
"""If confirm is required and denied, return an error string for the model.
|
||||
|
||||
The confirm hook may return ``True`` (allow), ``False`` (deny), or a
|
||||
non-empty string (deny with user comment for the model).
|
||||
"""
|
||||
if self._danger is None or self._on_confirm is None:
|
||||
return None
|
||||
reason = self._danger(name, args)
|
||||
@@ -223,12 +229,14 @@ class ToolRegistry:
|
||||
reason = self._danger(name, args)
|
||||
if reason is None:
|
||||
return None
|
||||
allowed = self._on_confirm(name, args, reason)
|
||||
if inspect.isawaitable(allowed):
|
||||
allowed = await allowed
|
||||
if allowed:
|
||||
decision = self._on_confirm(name, args, reason)
|
||||
if inspect.isawaitable(decision):
|
||||
decision = await decision
|
||||
if decision is True:
|
||||
return None
|
||||
return f"error: user denied tool {name!r} ({reason})"
|
||||
if isinstance(decision, str) and decision.strip():
|
||||
return f"error: tool {name!r} denied by user confirm ({reason}); user comment: {decision.strip()}"
|
||||
return f"error: tool {name!r} denied by user confirm ({reason})"
|
||||
|
||||
async def execute(self, name: str, arguments_json: str) -> str:
|
||||
"""Run a tool by name; returns a string result (errors become error text)."""
|
||||
|
||||
@@ -257,7 +257,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
||||
try:
|
||||
if client_supports_models(client):
|
||||
remote_ids = await fetch_remote_model_ids(client)
|
||||
except (RuntimeError, TypeError, OSError, ValueError):
|
||||
except RuntimeError, TypeError, OSError, ValueError:
|
||||
remote_ids = None
|
||||
choices = model_choices_for_provider(provider, remote_ids=remote_ids)
|
||||
model_id = select_model(
|
||||
@@ -307,8 +307,10 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
||||
finally:
|
||||
await memory.close()
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
from plyngent.tools.temp_workspace import cleanup_temporary_workspaces
|
||||
|
||||
PtyManager.close_all()
|
||||
_ = cleanup_temporary_workspaces()
|
||||
|
||||
|
||||
def _configure_logging(level: str) -> None:
|
||||
|
||||
@@ -4,7 +4,6 @@ import asyncio
|
||||
import contextlib
|
||||
import signal
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -13,13 +12,16 @@ if TYPE_CHECKING:
|
||||
|
||||
type SigHandler = Callable[[int, FrameType | None], None] | int | None
|
||||
|
||||
_allow_task_cancel: ContextVar[bool] = ContextVar("allow_task_cancel", default=True)
|
||||
# Depth of nested pause_task_cancel_for_prompt. Must be a plain int (not ContextVar):
|
||||
# asyncio.add_signal_handler freezes the ContextVar snapshot at install time, so a
|
||||
# ContextVar reset after reinstall would leave SIGINT permanently non-cancelling.
|
||||
_prompt_depth: int = 0
|
||||
_reinstall_holder: list[Callable[[], None] | None] = [None]
|
||||
|
||||
|
||||
def allow_task_cancel() -> bool:
|
||||
"""Whether the CLI SIGINT handler should cancel the in-flight turn task."""
|
||||
return _allow_task_cancel.get()
|
||||
return _prompt_depth == 0
|
||||
|
||||
|
||||
def set_sigint_reinstall(callback: Callable[[], None] | None) -> None:
|
||||
@@ -34,8 +36,14 @@ def pause_task_cancel_for_prompt() -> Generator[None]:
|
||||
Must run on the main thread (signal handlers are main-thread only).
|
||||
Restores the default SIGINT handler so ``click.confirm`` can receive
|
||||
KeyboardInterrupt / Abort instead of the asyncio turn being cancelled.
|
||||
|
||||
Nested prompts are supported via a depth counter. The asyncio SIGINT
|
||||
handler is reinstalled only after depth returns to 0, and only after
|
||||
cancel is re-enabled, so the handler never freezes ``allow=False``.
|
||||
"""
|
||||
token = _allow_task_cancel.set(False)
|
||||
|
||||
global _prompt_depth # noqa: PLW0603
|
||||
_prompt_depth += 1
|
||||
loop_handler_removed = False
|
||||
previous: SigHandler = signal.SIG_DFL
|
||||
try:
|
||||
@@ -56,11 +64,15 @@ def pause_task_cancel_for_prompt() -> Generator[None]:
|
||||
finally:
|
||||
with contextlib.suppress(ValueError):
|
||||
_ = signal.signal(signal.SIGINT, previous)
|
||||
_prompt_depth = max(0, _prompt_depth - 1)
|
||||
# Reinstall only when fully out of prompts and cancel is allowed again.
|
||||
# Order matters: decrement depth first so allow_task_cancel() is True
|
||||
# before reinstall (and handlers never freeze allow=False).
|
||||
if _prompt_depth == 0 and loop_handler_removed:
|
||||
reinstall = _reinstall_holder[0]
|
||||
if loop_handler_removed and reinstall is not None:
|
||||
if reinstall is not None:
|
||||
with contextlib.suppress(RuntimeError, NotImplementedError, ValueError):
|
||||
reinstall()
|
||||
_allow_task_cancel.reset(token)
|
||||
|
||||
|
||||
async def run_in_prompt_thread[**P, R](func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
|
||||
+118
-13
@@ -1,16 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from plyngent.cli.interrupt import pause_task_cancel_for_prompt
|
||||
from plyngent.prompting import (
|
||||
ChoiceOption,
|
||||
NonInteractiveError,
|
||||
ask,
|
||||
ask_async,
|
||||
choose,
|
||||
choose_async,
|
||||
configure_prompting,
|
||||
confirm,
|
||||
confirm_async,
|
||||
get_prompt_backend,
|
||||
)
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
|
||||
@@ -19,6 +23,78 @@ if TYPE_CHECKING:
|
||||
|
||||
type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"]
|
||||
|
||||
_BOX_MIN_WIDTH = 40
|
||||
_BOX_MAX_WIDTH = 100
|
||||
_BOX_PAD = 2 # spaces inside left/right borders
|
||||
|
||||
|
||||
def _terminal_width() -> int:
|
||||
try:
|
||||
return max(_BOX_MIN_WIDTH, min(_BOX_MAX_WIDTH, shutil.get_terminal_size(fallback=(80, 24)).columns))
|
||||
except OSError:
|
||||
return 80
|
||||
|
||||
|
||||
def _wrap_line(text: str, width: int) -> list[str]:
|
||||
"""Hard-wrap a single logical line to *width* (no word-break library)."""
|
||||
width = max(width, 8)
|
||||
if not text:
|
||||
return [""]
|
||||
if len(text) <= width:
|
||||
return [text]
|
||||
out: list[str] = []
|
||||
rest = text
|
||||
while rest:
|
||||
if len(rest) <= width:
|
||||
out.append(rest)
|
||||
break
|
||||
# Prefer break at last space in the window.
|
||||
window = rest[:width]
|
||||
break_at = window.rfind(" ")
|
||||
if break_at >= width // 2:
|
||||
out.append(rest[:break_at])
|
||||
rest = rest[break_at + 1 :]
|
||||
else:
|
||||
out.append(rest[:width])
|
||||
rest = rest[width:]
|
||||
return out
|
||||
|
||||
|
||||
def format_tool_confirm_box(name: str, reason: str) -> str:
|
||||
"""Multi-line boxed confirm body (header + reason lines).
|
||||
|
||||
Printed with :meth:`PromptBackend.echo` before a short ``confirm()`` prompt
|
||||
so terminals keep newlines (unlike a single jammed readline line).
|
||||
"""
|
||||
term = _terminal_width()
|
||||
inner = max(20, term - 4) # room for ``│ `` + `` │``
|
||||
header = f"confirm · tool {name!r}"
|
||||
body_lines: list[str] = []
|
||||
for raw in reason.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
||||
body_lines.extend(_wrap_line(raw, inner - _BOX_PAD))
|
||||
content = [header, "─" * min(inner, max(len(header), 12)), *body_lines]
|
||||
width = min(inner, max(len(line) for line in content) + _BOX_PAD)
|
||||
width = max(width, min(inner, len(header) + _BOX_PAD))
|
||||
top = "┌" + "─" * (width + 2) + "┐"
|
||||
bottom = "└" + "─" * (width + 2) + "┘"
|
||||
rows = [top]
|
||||
for line in content:
|
||||
# Second line is a separator drawn with box dashes already in content.
|
||||
if line.startswith("─") and set(line) <= {"─"}:
|
||||
rows.append("├" + "─" * (width + 2) + "┤")
|
||||
continue
|
||||
padded = line[:width].ljust(width)
|
||||
rows.append(f"│ {padded} │")
|
||||
rows.append(bottom)
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def _echo_tool_confirm(name: str, reason: str) -> None:
|
||||
backend = get_prompt_backend()
|
||||
backend.echo()
|
||||
backend.secho(format_tool_confirm_box(name, reason), fg="yellow")
|
||||
backend.echo()
|
||||
|
||||
|
||||
def _prompt_continue_limit_sync(reason: str) -> bool:
|
||||
try:
|
||||
@@ -44,33 +120,62 @@ async def prompt_continue_limit_async(reason: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool:
|
||||
def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool | str:
|
||||
"""True allow; False deny; non-empty str = deny with comment for the model."""
|
||||
del args
|
||||
try:
|
||||
return confirm(
|
||||
f"[confirm] tool {name!r}: {reason}\nAllow this tool call?",
|
||||
default=False,
|
||||
)
|
||||
_echo_tool_confirm(name, reason)
|
||||
allowed = confirm("Allow this tool call?", default=False)
|
||||
except NonInteractiveError:
|
||||
return False
|
||||
if allowed:
|
||||
return True
|
||||
try:
|
||||
comment = ask(
|
||||
"Optional comment for the agent (why denied; empty to skip):",
|
||||
default="",
|
||||
).strip()
|
||||
except NonInteractiveError:
|
||||
return False
|
||||
return comment or False
|
||||
|
||||
|
||||
def prompt_confirm_tool(name: str, args: Mapping[str, object], reason: str) -> bool:
|
||||
"""Ask whether to allow a destructive tool call (TTY). Default is deny."""
|
||||
def prompt_confirm_tool(name: str, args: Mapping[str, object], reason: str) -> bool | str:
|
||||
"""Ask whether to allow a dangerous tool call (TTY). Default is deny.
|
||||
|
||||
Prints a multi-line boxed summary, then a short y/N prompt. On deny,
|
||||
optionally collect a free-text comment for the model.
|
||||
"""
|
||||
with pause_task_cancel_for_prompt():
|
||||
return _prompt_confirm_tool_sync(name, args, reason)
|
||||
|
||||
|
||||
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool:
|
||||
"""Async variant: confirm off the event loop."""
|
||||
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool | str:
|
||||
"""Async confirm: True allow, False deny, str = deny with user comment."""
|
||||
del args
|
||||
try:
|
||||
return await confirm_async(
|
||||
f"[confirm] tool {name!r}: {reason}\nAllow this tool call?",
|
||||
default=False,
|
||||
)
|
||||
# Box is printed inside the worker thread via confirm's backend.
|
||||
def _run() -> bool:
|
||||
_echo_tool_confirm(name, reason)
|
||||
return confirm("Allow this tool call?", default=False)
|
||||
|
||||
from plyngent.prompting import run_prompt_async
|
||||
|
||||
allowed = await run_prompt_async(_run)
|
||||
except NonInteractiveError:
|
||||
return False
|
||||
if allowed:
|
||||
return True
|
||||
try:
|
||||
comment = (
|
||||
await ask_async(
|
||||
"Optional comment for the agent (why denied; empty to skip):",
|
||||
default="",
|
||||
)
|
||||
).strip()
|
||||
except NonInteractiveError:
|
||||
return False
|
||||
return comment or False
|
||||
|
||||
|
||||
def _prompt_workspace_mismatch_sync(
|
||||
|
||||
@@ -54,8 +54,10 @@ def build_completer(state: ReplState) -> Callable[[str, int], str | None]:
|
||||
options = filter_prefix(text, slash_commands())
|
||||
else:
|
||||
head = buffer[:begidx].strip()
|
||||
command = head.split()[0] if head else ""
|
||||
options = complete_slash_args(state, command, text)
|
||||
tokens = head.split() if head else []
|
||||
command = tokens[0] if tokens else ""
|
||||
prior_args = tokens[1:] if len(tokens) > 1 else []
|
||||
options = complete_slash_args(state, command, text, prior_args=prior_args)
|
||||
if state_index < len(options):
|
||||
return options[state_index]
|
||||
return None
|
||||
|
||||
@@ -66,6 +66,9 @@ async def run_cancellable[T](coro: Coroutine[object, object, T]) -> T:
|
||||
installed = False
|
||||
|
||||
def _on_sigint() -> None:
|
||||
# allow_task_cancel() uses a process-level depth counter (not ContextVar)
|
||||
# so this remains correct even if the handler was installed under a
|
||||
# frozen context (asyncio signal handles capture contextvars).
|
||||
if allow_task_cancel() and not task.done():
|
||||
_ = task.cancel()
|
||||
|
||||
|
||||
+401
-37
@@ -15,7 +15,8 @@ from plyngent.cli.selection import select_model, select_provider
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantChatMessage,
|
||||
AssistantFunctionToolCall,
|
||||
ToolChatMessage,
|
||||
DeveloperChatMessage,
|
||||
SystemChatMessage,
|
||||
UserChatMessage,
|
||||
)
|
||||
from plyngent.runtime import ProviderNotSupportedError
|
||||
@@ -32,15 +33,58 @@ _COMPACT_PREVIEW = 400
|
||||
_ON_OFF_CHOICES = ("on", "off")
|
||||
_YOLO_MODE_CHOICES = ("on", "off", "once")
|
||||
_EXPORT_FORMAT_CHOICES = ("md", "json")
|
||||
_TODO_ACTION_CHOICES = (
|
||||
"list",
|
||||
"show",
|
||||
"ls",
|
||||
"push",
|
||||
"pop",
|
||||
"done",
|
||||
"cancel",
|
||||
"pending",
|
||||
"in_progress",
|
||||
"clear",
|
||||
)
|
||||
_ROUNDS_CHOICES = ("8", "16", "32", "64", "128")
|
||||
|
||||
|
||||
class HistoryLimitType(click.ParamType[int]):
|
||||
"""``N`` (int >= 1) or the shortcut ``last`` (equivalent to ``1``)."""
|
||||
|
||||
name: str = "history_limit"
|
||||
|
||||
@override
|
||||
def convert(self, value: object, param: click.Parameter | None, ctx: click.Context | None) -> int:
|
||||
if isinstance(value, int):
|
||||
if value < 1:
|
||||
self.fail("must be >= 1", param, ctx)
|
||||
return value
|
||||
text = str(value).strip().lower()
|
||||
if text == "last":
|
||||
return 1
|
||||
try:
|
||||
n = int(text, 10)
|
||||
except ValueError:
|
||||
self.fail("expected an integer N or 'last'", param, ctx)
|
||||
if n < 1:
|
||||
self.fail("must be >= 1", param, ctx)
|
||||
return n
|
||||
|
||||
@override
|
||||
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
||||
del ctx, param
|
||||
tokens = ("last", "1", "5", "10", "20")
|
||||
return [CompletionItem(token) for token in tokens if incomplete == "" or token.startswith(incomplete.lower())]
|
||||
|
||||
|
||||
HELP_FOOTER = (
|
||||
"User messages are saved immediately. On API errors or Ctrl+C, partial\n"
|
||||
"assistant/tool output is discarded but the user message stays (so /retry\n"
|
||||
"works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n"
|
||||
"\n"
|
||||
"Tab completes slash commands and some arguments (provider, model, tools,\n"
|
||||
"stream, verbose, yolo, export). Use --session ID or /resume to continue a prior\n"
|
||||
"chat after restart.\n"
|
||||
"Tab completes slash commands and arguments (provider, model, session ids,\n"
|
||||
"todos actions, on/off, yolo, export, flags). Use --session ID or /resume to\n"
|
||||
"continue a prior chat after restart.\n"
|
||||
"\n"
|
||||
'Multiline: start a message with """ then end a later line with """.\n'
|
||||
"Long prompts: /edit opens $EDITOR.\n"
|
||||
@@ -130,6 +174,80 @@ class ExportFormatParam(click.ParamType[str]):
|
||||
EXPORT_FORMAT = ExportFormatParam()
|
||||
|
||||
|
||||
class TodosActionParam(click.ParamType[str]):
|
||||
"""First token of ``/todos``: list|push|pop|done|…"""
|
||||
|
||||
name: str = "todos_action"
|
||||
|
||||
@override
|
||||
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> str:
|
||||
del param, ctx
|
||||
return str(value)
|
||||
|
||||
@override
|
||||
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
||||
del ctx, param
|
||||
return _filter_choices(incomplete, _TODO_ACTION_CHOICES)
|
||||
|
||||
|
||||
TODOS_ACTION = TodosActionParam()
|
||||
|
||||
|
||||
class SessionIdParam(click.ParamType[int]):
|
||||
"""Session id for ``/resume`` / ``/delete`` (from ReplState cache + current)."""
|
||||
|
||||
name: str = "session_id"
|
||||
|
||||
@override
|
||||
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> int:
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
text = str(value).strip()
|
||||
try:
|
||||
return int(text, 10)
|
||||
except ValueError:
|
||||
self.fail("expected a session id (integer)", param, ctx)
|
||||
|
||||
@override
|
||||
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
||||
del param
|
||||
state = _repl_state(ctx)
|
||||
if state is None:
|
||||
return []
|
||||
# Sync cache only — no async from readline Tab (no awaitlet greenlet).
|
||||
return _filter_choices(incomplete, state.session_ids_for_complete())
|
||||
|
||||
|
||||
SESSION_ID = SessionIdParam()
|
||||
|
||||
|
||||
class RoundsParam(click.ParamType[int]):
|
||||
"""Max tool-loop rounds (int >= 1); Tab suggests common values."""
|
||||
|
||||
name: str = "rounds"
|
||||
|
||||
@override
|
||||
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> int:
|
||||
if isinstance(value, int):
|
||||
n = value
|
||||
else:
|
||||
try:
|
||||
n = int(str(value).strip(), 10)
|
||||
except ValueError:
|
||||
self.fail("expected an integer >= 1", param, ctx)
|
||||
if n < 1:
|
||||
self.fail("max_rounds must be >= 1", param, ctx)
|
||||
return n
|
||||
|
||||
@override
|
||||
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
||||
del ctx, param
|
||||
return _filter_choices(incomplete, _ROUNDS_CHOICES)
|
||||
|
||||
|
||||
ROUNDS = RoundsParam()
|
||||
|
||||
|
||||
class ProviderNameParam(click.ParamType[str]):
|
||||
name: str = "provider"
|
||||
|
||||
@@ -232,21 +350,55 @@ def slash_command_names() -> list[str]:
|
||||
return sorted(f"/{name}" for name in slash.list_commands(ctx))
|
||||
|
||||
|
||||
def complete_slash_args(state: ReplState, command: str, incomplete: str) -> list[str]:
|
||||
"""Tab-complete arguments for ``command`` (e.g. ``/stream``) from ParamTypes.
|
||||
def complete_slash_args(
|
||||
state: ReplState,
|
||||
command: str,
|
||||
incomplete: str,
|
||||
*,
|
||||
prior_args: Sequence[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Tab-complete arguments/options for ``command`` from ParamTypes.
|
||||
|
||||
Uses the first :class:`click.Argument` on the registered command whose type
|
||||
implements :meth:`~click.ParamType.shell_complete` with candidates.
|
||||
*prior_args* are tokens already typed after the command name (so
|
||||
``/todos done t`` can complete todo ids). Options are completed when
|
||||
*incomplete* starts with ``-``.
|
||||
"""
|
||||
name = command.lstrip("/").lower()
|
||||
ctx = click.Context(slash, obj=state)
|
||||
cmd = slash.get_command(ctx, name)
|
||||
if cmd is None:
|
||||
return []
|
||||
prior = list(prior_args or ())
|
||||
|
||||
with click.Context(cmd, info_name=name, parent=ctx, obj=state) as sub:
|
||||
for param in cmd.params:
|
||||
if not isinstance(param, click.Argument):
|
||||
continue
|
||||
# Flag / option completion (e.g. --persist, --full).
|
||||
if incomplete.startswith("-"):
|
||||
flags = [
|
||||
opt
|
||||
for param in cmd.params
|
||||
if isinstance(param, click.Option)
|
||||
for opt in param.opts
|
||||
if opt.startswith(incomplete)
|
||||
]
|
||||
if flags:
|
||||
return sorted(set(flags))
|
||||
|
||||
# Contextual /todos second token: todo ids after done|cancel|pending|in_progress.
|
||||
if name == "todos" and prior:
|
||||
action = prior[0].lower()
|
||||
if action in {"done", "cancel", "pending", "in_progress"} and len(prior) == 1:
|
||||
ids = [item.id for item in state.todo_stack.all_items()]
|
||||
return [i for i in ids if i.startswith(incomplete)]
|
||||
|
||||
# Positional arguments: complete the next unset argument.
|
||||
arg_params = [p for p in cmd.params if isinstance(p, click.Argument)]
|
||||
arg_index = len(prior)
|
||||
if arg_index < len(arg_params):
|
||||
param = arg_params[arg_index]
|
||||
items = param.type.shell_complete(sub, param, incomplete)
|
||||
if items:
|
||||
return [item.value for item in items]
|
||||
for param in arg_params:
|
||||
items = param.type.shell_complete(sub, param, incomplete)
|
||||
if items:
|
||||
return [item.value for item in items]
|
||||
@@ -398,6 +550,7 @@ def status_cmd(state: ReplState) -> None:
|
||||
def sessions_cmd(state: ReplState) -> None:
|
||||
"""List sessions for this workspace (newest first)."""
|
||||
sessions = _await(state.memory.list_sessions(workspace=state.workspace))
|
||||
state.remember_session_ids([s.sid for s in sessions])
|
||||
if not sessions:
|
||||
click.echo(f"(no sessions for workspace {state.workspace})")
|
||||
return
|
||||
@@ -435,7 +588,7 @@ def rename_cmd(state: ReplState, name: tuple[str, ...]) -> None:
|
||||
|
||||
|
||||
@slash.command("delete")
|
||||
@click.argument("session_id", type=int, required=False)
|
||||
@click.argument("session_id", type=SESSION_ID, required=False)
|
||||
@click.pass_obj
|
||||
def delete_cmd(state: ReplState, session_id: int | None) -> None:
|
||||
"""Hard-delete a session (confirm; current → new empty)."""
|
||||
@@ -538,7 +691,7 @@ def export_cmd(state: ReplState, parts: tuple[str, ...]) -> None:
|
||||
|
||||
|
||||
@slash.command("resume")
|
||||
@click.argument("session_id", type=int, required=False)
|
||||
@click.argument("session_id", type=SESSION_ID, required=False)
|
||||
@click.pass_obj
|
||||
def resume_cmd(state: ReplState, session_id: int | None) -> None:
|
||||
"""Resume session id, or latest for this workspace if omitted."""
|
||||
@@ -576,6 +729,11 @@ def compact_cmd(state: ReplState, name: str | None) -> None:
|
||||
return
|
||||
preview = summary if len(summary) <= _COMPACT_PREVIEW else summary[:_COMPACT_PREVIEW] + "…"
|
||||
click.echo(f"compacted session {old_id} -> new session {new_id}")
|
||||
click.secho(
|
||||
f"active session is summary-only ({len(state.agent.messages)} messages); "
|
||||
f"full history remains on /resume {old_id}",
|
||||
fg="bright_black",
|
||||
)
|
||||
click.secho(preview, fg="bright_black")
|
||||
|
||||
|
||||
@@ -637,8 +795,13 @@ def provider_cmd(state: ReplState, name: str | None) -> None:
|
||||
|
||||
@slash.command("models")
|
||||
@click.option("--refresh", is_flag=True, help="Bypass cache and re-fetch GET /models.")
|
||||
@click.option(
|
||||
"--persist",
|
||||
is_flag=True,
|
||||
help="Merge remote (and recovered) model ids into plyngent.toml for this provider.",
|
||||
)
|
||||
@click.pass_obj
|
||||
def models_cmd(state: ReplState, *, refresh: bool) -> None:
|
||||
def models_cmd(state: ReplState, *, refresh: bool, persist: bool) -> None: # noqa: C901
|
||||
"""List models (remote-first, plus config-only ids). Always tries GET /models."""
|
||||
del refresh # always re-fetch; flag kept for CLI compatibility / docs
|
||||
remote: list[str] | None = None
|
||||
@@ -662,6 +825,14 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
|
||||
except (KeyError, ValueError) as exc:
|
||||
click.secho(f"could not recover provider: {exc}", fg="yellow", err=True)
|
||||
|
||||
if persist:
|
||||
try:
|
||||
path = state.persist_models_to_config(mode="catalog", catalog_ids=remote or ())
|
||||
click.echo(f"persisted {len(state.provider.models)} model(s) for {state.provider_name!r} to {path}")
|
||||
except (KeyError, ValueError, OSError) as exc:
|
||||
click.secho(f"error: could not persist models: {exc}", fg="red")
|
||||
return
|
||||
|
||||
config_ids = set(state.config_model_ids())
|
||||
choices = model_choices_for_provider(state.provider, remote_ids=remote)
|
||||
|
||||
@@ -691,12 +862,20 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
|
||||
|
||||
@slash.command("model")
|
||||
@click.argument("model_id", required=False, type=MODEL_ID)
|
||||
@click.option(
|
||||
"--persist",
|
||||
is_flag=True,
|
||||
help="Write the current (or newly selected) model id into plyngent.toml.",
|
||||
)
|
||||
@click.pass_obj
|
||||
def model_cmd(state: ReplState, model_id: str | None) -> None:
|
||||
"""Show or switch model (Tab: remote-first plus config; live fetch on pick)."""
|
||||
if not model_id:
|
||||
click.echo(f"model={state.model}")
|
||||
return
|
||||
def model_cmd(state: ReplState, model_id: str | None, *, persist: bool) -> None:
|
||||
"""Show or switch model (Tab: remote-first plus config; live fetch on pick).
|
||||
|
||||
``/model --persist`` saves the active model id into the provider catalog in
|
||||
TOML (faster Tab/list on next launch). ``/model <id> --persist`` switches
|
||||
then saves.
|
||||
"""
|
||||
if model_id:
|
||||
try:
|
||||
choices = _await(state.merged_model_choices(refresh=True))
|
||||
state.model = select_model(
|
||||
@@ -709,6 +888,17 @@ def model_cmd(state: ReplState, model_id: str | None) -> None:
|
||||
click.echo(f"switched model to {state.model}")
|
||||
except click.ClickException as exc:
|
||||
click.echo(f"error: {exc}")
|
||||
return
|
||||
elif not persist:
|
||||
click.echo(f"model={state.model}")
|
||||
return
|
||||
|
||||
if persist:
|
||||
try:
|
||||
path = state.persist_models_to_config(mode="current")
|
||||
click.echo(f"persisted model {state.model!r} for {state.provider_name!r} to {path}")
|
||||
except (KeyError, ValueError, OSError) as exc:
|
||||
click.secho(f"error: could not persist model: {exc}", fg="red")
|
||||
|
||||
|
||||
@slash.command("tools")
|
||||
@@ -800,45 +990,160 @@ def markdown_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT00
|
||||
|
||||
|
||||
@slash.command("rounds")
|
||||
@click.argument("n", required=False, type=int)
|
||||
@click.argument("n", required=False, type=ROUNDS)
|
||||
@click.pass_obj
|
||||
def rounds_cmd(state: ReplState, n: int | None) -> None:
|
||||
"""Show or set max tool-loop rounds."""
|
||||
if n is None:
|
||||
click.echo(f"max_rounds={state.max_rounds}")
|
||||
return
|
||||
if n < 1:
|
||||
msg = "max_rounds must be >= 1"
|
||||
raise click.UsageError(msg)
|
||||
state.max_rounds = n
|
||||
state.agent.max_rounds = n
|
||||
click.echo(f"max_rounds={state.max_rounds}")
|
||||
|
||||
|
||||
@slash.command("history")
|
||||
@click.argument("n", required=False, type=int)
|
||||
@click.argument("n", required=False, type=HistoryLimitType())
|
||||
@click.option(
|
||||
"--full",
|
||||
"mode_full",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Print full message bodies (markdown for assistant when TTY).",
|
||||
)
|
||||
@click.option(
|
||||
"--preview",
|
||||
"mode_preview",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Force short previews even for a single message.",
|
||||
)
|
||||
@click.pass_obj
|
||||
def history_cmd(state: ReplState, n: int | None) -> None:
|
||||
"""Show last n messages in this session (default 20)."""
|
||||
limit = _DEFAULT_HISTORY_LINES if n is None else n
|
||||
if limit < 1:
|
||||
msg = "n must be >= 1"
|
||||
def history_cmd(
|
||||
state: ReplState,
|
||||
n: int | None,
|
||||
*,
|
||||
mode_full: bool,
|
||||
mode_preview: bool,
|
||||
) -> None:
|
||||
"""Show recent messages (default: last 20 as short previews).
|
||||
|
||||
``/history last`` or ``/history 1`` shows the last message in full with Rich
|
||||
markdown when available. Use ``--full`` for multiple messages in full;
|
||||
``--preview`` forces the short form even for a single message.
|
||||
"""
|
||||
if mode_full and mode_preview:
|
||||
msg = "use only one of --full / --preview"
|
||||
raise click.UsageError(msg)
|
||||
limit = _DEFAULT_HISTORY_LINES if n is None else n
|
||||
messages = state.agent.messages
|
||||
if not messages:
|
||||
click.echo("(no messages in this session)")
|
||||
return
|
||||
start = max(0, len(messages) - limit)
|
||||
click.echo(f"session={state.session_id} messages={len(messages)} showing={len(messages) - start}")
|
||||
for offset, message in enumerate(messages[start:]):
|
||||
click.echo(_format_history_message(start + offset, message))
|
||||
slice_msgs = messages[start:]
|
||||
# Full: --full, or single-message window (last / 1) unless --preview.
|
||||
full = mode_full or (len(slice_msgs) == 1 and not mode_preview)
|
||||
mode_tag = "full" if full else "preview"
|
||||
click.echo(f"session={state.session_id} messages={len(messages)} showing={len(slice_msgs)} mode={mode_tag}")
|
||||
for offset, message in enumerate(slice_msgs):
|
||||
idx = start + offset
|
||||
if full:
|
||||
_print_history_message_full(idx, message)
|
||||
else:
|
||||
click.echo(_format_history_message(idx, message))
|
||||
if state.agent.pending_retry_text is not None:
|
||||
pending = state.agent.pending_retry_text
|
||||
if full:
|
||||
click.secho("(pending retry) user:", fg="yellow")
|
||||
click.echo(pending)
|
||||
else:
|
||||
click.secho(
|
||||
f"(pending retry) user: {_preview_content(state.agent.pending_retry_text)}",
|
||||
f"(pending retry) user: {_preview_content(pending)}",
|
||||
fg="yellow",
|
||||
)
|
||||
|
||||
|
||||
@slash.command("todos")
|
||||
@click.argument("action", required=False, type=TODOS_ACTION)
|
||||
@click.argument("rest", required=False, nargs=-1, type=str)
|
||||
@click.pass_obj
|
||||
def todos_cmd( # noqa: C901, PLR0911, PLR0912, PLR0915
|
||||
state: ReplState, action: str | None, rest: tuple[str, ...]
|
||||
) -> None:
|
||||
"""Show or edit the LIFO stack of **task groups**.
|
||||
|
||||
``/todos`` — list (top group first)
|
||||
``/todos push T1; T2`` — one new group of siblings
|
||||
``/todos pop`` — pop entire top group
|
||||
``/todos done <id>`` / ``/todos cancel <id>`` — set status
|
||||
``/todos clear`` — wipe stack
|
||||
"""
|
||||
from plyngent.agent.todo_stack import parse_push_titles
|
||||
|
||||
stack = state.todo_stack
|
||||
act = (action or "list").strip().lower()
|
||||
if act in {"list", "show", "ls"}:
|
||||
click.echo(stack.render())
|
||||
return
|
||||
if act == "push":
|
||||
raw = " ".join(rest).strip()
|
||||
if not raw:
|
||||
click.echo('error: usage: /todos push <title> | /todos push ["T1","T2"] | T1; T2')
|
||||
return
|
||||
titles = parse_push_titles(raw)
|
||||
if not titles:
|
||||
click.echo("error: no titles to push (use a JSON array or ; / newlines)")
|
||||
return
|
||||
try:
|
||||
group = stack.push_group(titles)
|
||||
except ValueError as exc:
|
||||
click.echo(f"error: {exc}")
|
||||
return
|
||||
_await(state.persist_todo_stack())
|
||||
ids = ", ".join(i.id for i in group.items)
|
||||
click.echo(f"pushed group depth={stack.depth} items=[{ids}]")
|
||||
click.echo(stack.render())
|
||||
return
|
||||
if act == "pop":
|
||||
group = stack.pop()
|
||||
if group is None:
|
||||
click.echo("todo stack empty")
|
||||
return
|
||||
_await(state.persist_todo_stack())
|
||||
titles = ", ".join(f"{i.id}:{i.title}" for i in group.items) or "(empty)"
|
||||
click.echo(f"popped TOP group ({titles})")
|
||||
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:
|
||||
@@ -858,9 +1163,71 @@ def _preview_content(text: str | None) -> str:
|
||||
return text[:_CONTENT_PREVIEW] + "…"
|
||||
|
||||
|
||||
def _print_history_assistant_full(index: int, message: AssistantChatMessage) -> None:
|
||||
from plyngent.cli.display import markdown_render_available, print_markdown
|
||||
|
||||
click.secho(f"{index}. assistant:", fg="cyan")
|
||||
content = message.content
|
||||
has_text = isinstance(content, str) and content.strip()
|
||||
if has_text and isinstance(content, str):
|
||||
if markdown_render_available():
|
||||
print_markdown(content, label="")
|
||||
else:
|
||||
click.echo(content)
|
||||
reasoning = message.reasoning_content
|
||||
has_reason = isinstance(reasoning, str) and reasoning.strip()
|
||||
if has_reason and isinstance(reasoning, str):
|
||||
click.secho("reasoning:", fg="bright_black")
|
||||
click.echo(reasoning)
|
||||
tool_calls = message.tool_calls
|
||||
has_tools = tool_calls is not UNSET and bool(tool_calls)
|
||||
if has_tools and tool_calls is not UNSET:
|
||||
for call in tool_calls:
|
||||
if isinstance(call, AssistantFunctionToolCall):
|
||||
click.secho(
|
||||
f" tool_call {call.id}: {call.function.name}({call.function.arguments})",
|
||||
fg="yellow",
|
||||
)
|
||||
else:
|
||||
click.secho(f" tool_call custom id={call.id}", fg="yellow")
|
||||
if not (has_text or has_reason or has_tools):
|
||||
click.echo("(empty)")
|
||||
click.echo()
|
||||
|
||||
|
||||
def _print_history_message_full(index: int, message: AnyChatMessage) -> None:
|
||||
"""Print one history message with full body; Rich markdown for assistant text."""
|
||||
if isinstance(message, UserChatMessage):
|
||||
click.secho(f"{index}. user:", fg="green")
|
||||
click.echo(message.content or "")
|
||||
click.echo()
|
||||
return
|
||||
if isinstance(message, DeveloperChatMessage):
|
||||
click.secho(f"{index}. developer:", fg="blue")
|
||||
click.echo(message.content or "")
|
||||
click.echo()
|
||||
return
|
||||
if isinstance(message, SystemChatMessage):
|
||||
click.secho(f"{index}. system:", fg="bright_black")
|
||||
click.echo(message.content or "")
|
||||
click.echo()
|
||||
return
|
||||
if isinstance(message, AssistantChatMessage):
|
||||
_print_history_assistant_full(index, message)
|
||||
return
|
||||
# ToolChatMessage (remaining AnyChatMessage arm)
|
||||
click.secho(f"{index}. tool({message.tool_call_id}):", fg="magenta")
|
||||
click.echo(message.content or "")
|
||||
click.echo()
|
||||
|
||||
|
||||
def _format_history_message(index: int, message: AnyChatMessage) -> str:
|
||||
if isinstance(message, UserChatMessage):
|
||||
return f"{index}. user: {_preview_content(message.content)}"
|
||||
if isinstance(message, DeveloperChatMessage):
|
||||
return f"{index}. developer: {_preview_content(message.content)}"
|
||||
if isinstance(message, SystemChatMessage):
|
||||
return f"{index}. system: {_preview_content(message.content)}"
|
||||
if isinstance(message, AssistantChatMessage):
|
||||
parts: list[str] = []
|
||||
if isinstance(message.content, str) and message.content:
|
||||
@@ -876,11 +1243,8 @@ def _format_history_message(index: int, message: AnyChatMessage) -> str:
|
||||
parts.append(f"tool_calls=[{', '.join(names)}]")
|
||||
body = " ".join(parts) if parts else "(empty)"
|
||||
return f"{index}. assistant: {body}"
|
||||
if isinstance(message, ToolChatMessage):
|
||||
# ToolChatMessage
|
||||
return f"{index}. tool({message.tool_call_id}): {_preview_content(message.content)}"
|
||||
role = getattr(message, "role", type(message).__name__)
|
||||
content = getattr(message, "content", "")
|
||||
return f"{index}. {role}: {_preview_content(str(content))}"
|
||||
|
||||
|
||||
def _run_slash_argv(args: Sequence[str], state: ReplState) -> None:
|
||||
|
||||
+111
-3
@@ -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,9 +18,11 @@ 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
|
||||
|
||||
from plyngent.config.models import Provider
|
||||
from plyngent.config.store import ConfigStore
|
||||
from plyngent.memory import MemoryStore
|
||||
@@ -54,6 +57,10 @@ 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)
|
||||
# Session ids for Tab complete (updated when listing/creating/resuming).
|
||||
_session_id_cache: list[int] = field(default_factory=list, 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)
|
||||
@@ -66,6 +73,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
|
||||
@@ -107,6 +115,44 @@ 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
|
||||
@@ -140,18 +186,22 @@ 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:
|
||||
"""Recreate client and agent after provider/model/tools change."""
|
||||
messages = list(self.agent.messages)
|
||||
persist_from = self.agent.persist_from
|
||||
# Preserve live stream toggle if agent already exists.
|
||||
if hasattr(self, "agent"):
|
||||
self.stream_enabled = self.agent.stream
|
||||
self.client = cast("ChatClient", cast("object", create_client(self.provider)))
|
||||
self.agent = self._make_agent()
|
||||
self.agent.messages = messages
|
||||
# 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()
|
||||
@@ -174,6 +224,22 @@ class ReplState:
|
||||
self._remote_models_key = self._models_cache_key()
|
||||
self._remote_models_error = None
|
||||
|
||||
def remember_session_ids(self, ids: Sequence[int]) -> None:
|
||||
"""Cache session ids for Tab completion (``/resume`` / ``/delete``)."""
|
||||
self._session_id_cache = [int(i) for i in ids]
|
||||
|
||||
def session_ids_for_complete(self) -> list[str]:
|
||||
"""String session ids for completers (cache + current session if any)."""
|
||||
seen: set[int] = set()
|
||||
out: list[str] = []
|
||||
for sid in self._session_id_cache:
|
||||
if sid not in seen:
|
||||
seen.add(sid)
|
||||
out.append(str(sid))
|
||||
if self.session_id is not None and self.session_id not in seen:
|
||||
out.insert(0, str(self.session_id))
|
||||
return out
|
||||
|
||||
def cached_remote_models(self) -> list[str] | None:
|
||||
"""Return cached remote ids if still valid for the current provider."""
|
||||
if self._remote_models is None or self._remote_models_fetched_at is None:
|
||||
@@ -290,6 +356,28 @@ class ReplState:
|
||||
model=self.model,
|
||||
)
|
||||
|
||||
def persist_models_to_config(
|
||||
self,
|
||||
*,
|
||||
mode: Literal["current", "catalog"],
|
||||
catalog_ids: Sequence[str] | None = None,
|
||||
) -> Path:
|
||||
"""Merge model id(s) into TOML for the current provider and write disk.
|
||||
|
||||
*mode* ``current``: ensure :attr:`model` is in the provider catalog.
|
||||
*mode* ``catalog``: union *catalog_ids* (or empty) into the catalog.
|
||||
|
||||
Returns the config path written. Raises ``OSError`` / ``ValueError`` /
|
||||
``KeyError`` on failure.
|
||||
"""
|
||||
if mode == "current":
|
||||
self.provider = self.config.ensure_model(self.provider_name, self.model)
|
||||
else:
|
||||
ids = list(catalog_ids) if catalog_ids is not None else []
|
||||
self.provider = self.config.merge_models(self.provider_name, ids)
|
||||
self.config.write()
|
||||
return self.config.path
|
||||
|
||||
def _try_set_provider(self, pname: str) -> bool:
|
||||
import click
|
||||
|
||||
@@ -353,7 +441,11 @@ class ReplState:
|
||||
model=self.model,
|
||||
)
|
||||
self.session_id = session.sid
|
||||
if session.sid not in self._session_id_cache:
|
||||
self._session_id_cache.insert(0, 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:
|
||||
@@ -404,11 +496,14 @@ class ReplState:
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
self.session_id = session_id
|
||||
if session_id not in self._session_id_cache:
|
||||
self._session_id_cache.insert(0, session_id)
|
||||
if self.apply_session_llm(row):
|
||||
self.rebuild_client()
|
||||
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."""
|
||||
@@ -423,6 +518,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"
|
||||
|
||||
@@ -430,6 +526,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
|
||||
|
||||
@@ -461,6 +558,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
|
||||
@@ -474,7 +572,17 @@ class ReplState:
|
||||
source_session_id=old_id,
|
||||
seed_text=self.config.agent_config.compact_seed_text or None,
|
||||
)
|
||||
self.agent.messages = list(seed)
|
||||
for message in seed:
|
||||
_ = await self.memory.append_message(new_id, message)
|
||||
# Reload from DB so RAM matches stored rows and the persist cursor is correct
|
||||
# (assigning messages alone left _persist_from at 0 and broke later checkpoints).
|
||||
await self.agent.load_history()
|
||||
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
|
||||
|
||||
@@ -200,6 +200,59 @@ class ConfigStore:
|
||||
_ = self._bad_providers.pop(name, None)
|
||||
return promoted
|
||||
|
||||
def _take_provider(self, name: str) -> Provider:
|
||||
"""Return a ready or recoverable provider, promoting recoverable into ready map."""
|
||||
if name in self._providers:
|
||||
return self._providers[name]
|
||||
if name in self._recoverable_providers:
|
||||
provider = self._recoverable_providers.pop(name)
|
||||
self._providers[name] = provider
|
||||
_ = self._bad_providers.pop(name, None)
|
||||
return provider
|
||||
msg = f"unknown provider {name!r}"
|
||||
raise KeyError(msg)
|
||||
|
||||
def ensure_model(self, name: str, model_id: str) -> Provider:
|
||||
"""Ensure *model_id* exists under ``providers[name].models`` (in memory).
|
||||
|
||||
Does not write the TOML file unless the caller invokes :meth:`write`.
|
||||
"""
|
||||
mid = model_id.strip()
|
||||
if not mid:
|
||||
msg = "model id must not be empty"
|
||||
raise ValueError(msg)
|
||||
provider = self._take_provider(name)
|
||||
if mid in provider.models:
|
||||
return provider
|
||||
models = dict(provider.models)
|
||||
models[mid] = ModelConfig()
|
||||
updated = msgspec.structs.replace(provider, models=models)
|
||||
self._providers[name] = updated
|
||||
return updated
|
||||
|
||||
def merge_models(self, name: str, model_ids: Sequence[str]) -> Provider:
|
||||
"""Union *model_ids* into the provider catalog (keep existing configs).
|
||||
|
||||
Does not write the TOML file unless the caller invokes :meth:`write`.
|
||||
"""
|
||||
provider = self._take_provider(name)
|
||||
models = dict(provider.models)
|
||||
added = False
|
||||
for raw in model_ids:
|
||||
mid = raw.strip() if raw else ""
|
||||
if not mid or mid in models:
|
||||
continue
|
||||
models[mid] = ModelConfig()
|
||||
added = True
|
||||
if not added and models:
|
||||
return provider
|
||||
if not models:
|
||||
msg = f"cannot merge models for provider {name!r}: no model ids"
|
||||
raise ValueError(msg)
|
||||
updated = msgspec.structs.replace(provider, models=models)
|
||||
self._providers[name] = updated
|
||||
return updated
|
||||
|
||||
# -- persistence --
|
||||
|
||||
def write(self) -> None:
|
||||
|
||||
@@ -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,17 @@ 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 .process import write_pty_keys as write_pty_keys
|
||||
from .temp_workspace import cleanup_temporary_workspaces as cleanup_temporary_workspaces
|
||||
from .temp_workspace import new_temporary_workspace as new_temporary_workspace
|
||||
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
|
||||
@@ -31,14 +42,19 @@ from .workspace import (
|
||||
DEFAULT_COMMAND_DENYLIST as DEFAULT_COMMAND_DENYLIST,
|
||||
)
|
||||
from .workspace import WorkspaceError as WorkspaceError
|
||||
from .workspace import add_workspace_allowlist as add_workspace_allowlist
|
||||
from .workspace import check_command_allowed as check_command_allowed
|
||||
from .workspace import clear_workspace_allowlist as clear_workspace_allowlist
|
||||
from .workspace import clear_workspace_root as clear_workspace_root
|
||||
from .workspace import get_command_denylist as get_command_denylist
|
||||
from .workspace import get_path_denylist as get_path_denylist
|
||||
from .workspace import get_workspace_root as get_workspace_root
|
||||
from .workspace import list_workspace_allowlist as list_workspace_allowlist
|
||||
from .workspace import pop_owned_temporary_workspaces as pop_owned_temporary_workspaces
|
||||
from .workspace import remove_workspace_allowlist as remove_workspace_allowlist
|
||||
from .workspace import resolve_path as resolve_path
|
||||
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]
|
||||
|
||||
+172
-24
@@ -1,11 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from plyngent.tools.workspace import WorkspaceError, resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
_DISPLAY_ARGV_MAX = 400
|
||||
_CODE_PREVIEW_MAX = 240
|
||||
|
||||
_SHELL_BASENAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"bash",
|
||||
"sh",
|
||||
"zsh",
|
||||
"fish",
|
||||
"dash",
|
||||
"ksh",
|
||||
"csh",
|
||||
"tcsh",
|
||||
"powershell",
|
||||
"pwsh",
|
||||
"cmd",
|
||||
"cmd.exe",
|
||||
"python",
|
||||
"python3",
|
||||
"python2",
|
||||
"ipython",
|
||||
"ipython3",
|
||||
"node",
|
||||
"nodejs",
|
||||
"deno",
|
||||
"bun",
|
||||
"ruby",
|
||||
"perl",
|
||||
"php",
|
||||
"lua",
|
||||
"r",
|
||||
"julia",
|
||||
"irb",
|
||||
"pry",
|
||||
"ghci",
|
||||
"scala",
|
||||
"jshell",
|
||||
"sqlite3",
|
||||
"psql",
|
||||
"mysql",
|
||||
"mongo",
|
||||
"redis-cli",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _basename(argv0: str) -> str:
|
||||
name = argv0.replace("\\", "/").rsplit("/", 1)[-1]
|
||||
name = name.removesuffix(".exe")
|
||||
return name.lower()
|
||||
|
||||
|
||||
def _as_argv(args: Mapping[str, object]) -> list[str] | None:
|
||||
command = args.get("command")
|
||||
if not isinstance(command, list) or not command:
|
||||
return None
|
||||
out: list[str] = []
|
||||
for part_obj in cast("list[object]", command):
|
||||
if not isinstance(part_obj, str):
|
||||
return None
|
||||
out.append(part_obj)
|
||||
return out
|
||||
|
||||
|
||||
def _find_dash_c_code(argv: Sequence[str]) -> str | None:
|
||||
"""Return the argument after ``-c`` / ``-c…`` if present (python/bash/node style)."""
|
||||
for index, part in enumerate(argv[1:], start=1):
|
||||
if part == "-c":
|
||||
return argv[index + 1] if index + 1 < len(argv) else ""
|
||||
# Combined short forms are uncommon; only exact -c is supported.
|
||||
return None
|
||||
|
||||
|
||||
def _shell_or_dash_c_reason(argv: Sequence[str], *, via: str) -> str | None:
|
||||
"""Confirm interactive shells/REPLs and ``-c`` one-liners so the user can inspect argv.
|
||||
|
||||
Multi-line reason (shown inside the CLI confirm box). ``via`` is a short
|
||||
label (tool name), not repeated on every line.
|
||||
"""
|
||||
if not argv:
|
||||
return None
|
||||
base = _basename(argv[0])
|
||||
display = " ".join(argv)
|
||||
if len(display) > _DISPLAY_ARGV_MAX:
|
||||
display = display[:_DISPLAY_ARGV_MAX] + "…"
|
||||
|
||||
code = _find_dash_c_code(argv)
|
||||
if code is not None:
|
||||
preview = code if len(code) <= _CODE_PREVIEW_MAX else code[:_CODE_PREVIEW_MAX] + "…"
|
||||
return f"{via}: {base} -c (review code before allow)\nargv:\n {display}\n-c code:\n {preview}"
|
||||
|
||||
if base in _SHELL_BASENAMES and len(argv) == 1:
|
||||
return f"{via}: interactive {base!r} (review before allow)\nargv:\n {display}"
|
||||
|
||||
# e.g. python -i, bash --login without -c still needs a glance.
|
||||
if base in _SHELL_BASENAMES:
|
||||
return f"{via}: shell/runtime {base!r} (review before allow)\nargv:\n {display}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _write_file_reason(args: Mapping[str, object]) -> str | None:
|
||||
@@ -15,32 +115,80 @@ def _write_file_reason(args: Mapping[str, object]) -> str | None:
|
||||
try:
|
||||
target = resolve_path(path)
|
||||
except WorkspaceError:
|
||||
return None
|
||||
if target.exists() or target.is_symlink():
|
||||
return f"overwrite existing file {path!r}"
|
||||
return None
|
||||
return f"write file {path!r}"
|
||||
return f"write file {path!r} ({target})"
|
||||
|
||||
|
||||
def classify_danger(name: str, args: Mapping[str, object]) -> str | None:
|
||||
def _edit_replace_reason(args: Mapping[str, object]) -> str | None:
|
||||
path = args.get("path")
|
||||
if not isinstance(path, str) or not path:
|
||||
return None
|
||||
return f"edit (replace) in {path!r}"
|
||||
|
||||
|
||||
def _edit_lineno_reason(args: Mapping[str, object]) -> str | None:
|
||||
path = args.get("path")
|
||||
if not isinstance(path, str) or not path:
|
||||
return None
|
||||
start = args.get("start_line")
|
||||
end = args.get("end_line")
|
||||
return f"edit lines {start}-{end} in {path!r}"
|
||||
|
||||
|
||||
def _copy_path_reason(args: Mapping[str, object]) -> str | None:
|
||||
src = args.get("src")
|
||||
dst = args.get("dst")
|
||||
return f"copy {src!r} → {dst!r}"
|
||||
|
||||
|
||||
def _move_path_reason(args: Mapping[str, object]) -> str | None:
|
||||
src = args.get("src")
|
||||
dst = args.get("dst")
|
||||
return f"move {src!r} → {dst!r}"
|
||||
|
||||
|
||||
def _delete_path_reason(args: Mapping[str, object]) -> str | None:
|
||||
path = args.get("path")
|
||||
recursive = bool(args.get("recursive", False))
|
||||
extra = " recursively" if recursive else ""
|
||||
return f"delete path {path!r}{extra}"
|
||||
|
||||
|
||||
def _run_command_reason(args: Mapping[str, object]) -> str | None:
|
||||
argv = _as_argv(args)
|
||||
if argv is None:
|
||||
return None
|
||||
return _shell_or_dash_c_reason(argv, via="run_command")
|
||||
|
||||
|
||||
def _open_pty_reason(args: Mapping[str, object]) -> str | None:
|
||||
argv = _as_argv(args)
|
||||
if argv is None:
|
||||
return None
|
||||
return _shell_or_dash_c_reason(argv, via="open_pty")
|
||||
|
||||
|
||||
def classify_danger(name: str, args: Mapping[str, object]) -> str | None: # noqa: PLR0911
|
||||
"""Return a short reason if ``name``/``args`` need user confirm, else ``None``.
|
||||
|
||||
Hard denylists (paths/commands) still raise independently. This only covers
|
||||
soft confirms for mutating tools that policy otherwise allows.
|
||||
soft confirms for mutating tools and risky shell/REPL launches
|
||||
(interactive shells and ``python -c`` / ``bash -c`` one-liners).
|
||||
"""
|
||||
reasons: dict[str, str | None] = {
|
||||
"delete_path": (
|
||||
f"delete path {args.get('path', '')!r} recursively"
|
||||
if bool(args.get("recursive", False))
|
||||
else f"delete path {args.get('path', '')!r}"
|
||||
),
|
||||
"move_path": f"move {args.get('src', '')!r} -> {args.get('dst', '')!r}",
|
||||
"copy_path": (
|
||||
f"copy with overwrite {args.get('src', '')!r} -> {args.get('dst', '')!r}"
|
||||
if bool(args.get("overwrite", False))
|
||||
else None
|
||||
),
|
||||
"write_file": _write_file_reason(args),
|
||||
}
|
||||
if name not in reasons:
|
||||
if name == "delete_path":
|
||||
return _delete_path_reason(args)
|
||||
if name == "move_path":
|
||||
return _move_path_reason(args)
|
||||
if name == "copy_path":
|
||||
return _copy_path_reason(args)
|
||||
if name == "write_file":
|
||||
return _write_file_reason(args)
|
||||
if name == "edit_replace":
|
||||
return _edit_replace_reason(args)
|
||||
if name == "edit_lineno":
|
||||
return _edit_lineno_reason(args)
|
||||
if name == "run_command":
|
||||
return _run_command_reason(args)
|
||||
if name == "open_pty":
|
||||
return _open_pty_reason(args)
|
||||
return None
|
||||
return reasons[name]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from plyngent.tools.temp_workspace import new_temporary_workspace as new_temporary_workspace
|
||||
|
||||
from .edit_lineno import edit_lineno as edit_lineno
|
||||
from .edit_replace import edit_replace as edit_replace
|
||||
from .fs_ops import copy_path as copy_path
|
||||
@@ -22,4 +24,5 @@ FILE_TOOLS = [
|
||||
copy_path,
|
||||
move_path,
|
||||
delete_path,
|
||||
new_temporary_workspace,
|
||||
]
|
||||
|
||||
@@ -3,12 +3,33 @@ from __future__ import annotations
|
||||
from plyngent.agent import tool
|
||||
from plyngent.tools.workspace import resolve_path
|
||||
|
||||
_LINENO_WIDTH = 6
|
||||
|
||||
|
||||
def _format_with_lineno(lines: list[str], *, start_lineno: int) -> str:
|
||||
"""Prefix each line with a 1-based absolute line number (``edit_lineno`` style)."""
|
||||
out: list[str] = []
|
||||
for index, line in enumerate(lines):
|
||||
lineno = start_lineno + index
|
||||
# Strip keepends for the body; re-add a single newline after the prefix.
|
||||
body = line.rstrip("\r\n")
|
||||
out.append(f"{lineno:>{_LINENO_WIDTH}}|{body}\n")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
@tool
|
||||
def read_file(path: str, *, offset: int = 0, limit: int | None = None) -> str:
|
||||
def read_file(
|
||||
path: str,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int | None = None,
|
||||
with_lineno: bool = False,
|
||||
) -> str:
|
||||
"""Read a text file under the workspace.
|
||||
|
||||
``offset`` is 0-based line start; ``limit`` is max lines (None = rest of file).
|
||||
When ``with_lineno`` is true, each line is prefixed with its 1-based file line
|
||||
number (`` N|…``), matching ``edit_lineno`` numbering.
|
||||
"""
|
||||
target = resolve_path(path)
|
||||
if not target.is_file():
|
||||
@@ -21,4 +42,7 @@ def read_file(path: str, *, offset: int = 0, limit: int | None = None) -> str:
|
||||
end = len(lines) if limit is None else min(len(lines), start + limit)
|
||||
if start >= len(lines):
|
||||
return ""
|
||||
return "".join(lines[start:end])
|
||||
slice_lines = lines[start:end]
|
||||
if with_lineno:
|
||||
return _format_with_lineno(slice_lines, start_lineno=start + 1)
|
||||
return "".join(slice_lines)
|
||||
|
||||
@@ -4,9 +4,10 @@ from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import tool
|
||||
from plyngent.tools.workspace import WorkspaceError, resolve_path
|
||||
from plyngent.tools.workspace import WorkspaceError, get_path_denylist, resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_MAX_DEPTH = 4
|
||||
@@ -26,21 +27,58 @@ VCS_DIR_NAMES: frozenset[str] = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# Extra noise dirs skipped by default (in addition to VCS / optional hidden).
|
||||
# Pass skip_dirs=[] to disable this list (VCS still always skipped).
|
||||
DEFAULT_NOISE_DIR_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
"dist",
|
||||
"build",
|
||||
"target",
|
||||
".tox",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
".pytest_cache",
|
||||
"coverage",
|
||||
".next",
|
||||
".nuxt",
|
||||
".turbo",
|
||||
".cache",
|
||||
"eggs",
|
||||
".eggs",
|
||||
"htmlcov",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _TreeLimits:
|
||||
max_depth: int
|
||||
max_entries: int
|
||||
skip_hidden_dirs: bool
|
||||
skip_basenames: frozenset[str]
|
||||
apply_path_denylist: bool
|
||||
|
||||
|
||||
def _skip_directory(name: str, *, skip_hidden_dirs: bool) -> bool:
|
||||
if name in VCS_DIR_NAMES:
|
||||
def _skip_directory(name: str, *, limits: _TreeLimits) -> bool:
|
||||
if name in VCS_DIR_NAMES or name in limits.skip_basenames:
|
||||
return True
|
||||
return bool(skip_hidden_dirs and name.startswith("."))
|
||||
return bool(limits.skip_hidden_dirs and name.startswith("."))
|
||||
|
||||
|
||||
def _list_children(directory: Path, *, skip_hidden_dirs: bool) -> list[Path] | str:
|
||||
def _path_denied(path: Path) -> bool:
|
||||
"""True when resolved path matches a path_denylist substring."""
|
||||
denylist = get_path_denylist()
|
||||
if not denylist:
|
||||
return False
|
||||
resolved_str = str(path).replace("\\", "/")
|
||||
return any(pattern and pattern.replace("\\", "/") in resolved_str for pattern in denylist)
|
||||
|
||||
|
||||
def _list_children(directory: Path, *, limits: _TreeLimits) -> list[Path] | str:
|
||||
try:
|
||||
children = list(directory.iterdir())
|
||||
except OSError as exc:
|
||||
@@ -51,7 +89,9 @@ def _list_children(directory: Path, *, skip_hidden_dirs: bool) -> list[Path] | s
|
||||
is_dir = child.is_dir()
|
||||
except OSError:
|
||||
continue
|
||||
if is_dir and _skip_directory(child.name, skip_hidden_dirs=skip_hidden_dirs):
|
||||
if is_dir and _skip_directory(child.name, limits=limits):
|
||||
continue
|
||||
if limits.apply_path_denylist and _path_denied(child):
|
||||
continue
|
||||
visible.append(child)
|
||||
# Directories first, then files; alphabetical within each group.
|
||||
@@ -70,7 +110,7 @@ def _render_tree(
|
||||
if depth >= limits.max_depth:
|
||||
return
|
||||
|
||||
children = _list_children(directory, skip_hidden_dirs=limits.skip_hidden_dirs)
|
||||
children = _list_children(directory, limits=limits)
|
||||
if isinstance(children, str):
|
||||
lines.append(f"{prefix}{children}")
|
||||
return
|
||||
@@ -104,6 +144,13 @@ def _render_tree(
|
||||
lines.append(f"{prefix}└── … ({more} more entries not shown)")
|
||||
|
||||
|
||||
def _resolve_skip_basenames(skip_dirs: Sequence[str] | None) -> frozenset[str]:
|
||||
"""None → default noise set; explicit list (including empty) replaces defaults."""
|
||||
if skip_dirs is None:
|
||||
return DEFAULT_NOISE_DIR_NAMES
|
||||
return frozenset(name for name in skip_dirs if name)
|
||||
|
||||
|
||||
@tool
|
||||
def tree(
|
||||
path: str = ".",
|
||||
@@ -111,13 +158,22 @@ def tree(
|
||||
max_depth: int = DEFAULT_MAX_DEPTH,
|
||||
max_entries: int = DEFAULT_MAX_ENTRIES,
|
||||
skip_hidden_dirs: bool = True,
|
||||
skip_dirs: list[str] | None = None,
|
||||
apply_path_denylist: bool = True,
|
||||
) -> str:
|
||||
"""Show a directory tree under the workspace.
|
||||
|
||||
Always skips VCS metadata directories (``.git``, ``.hg``, ``.svn``, …).
|
||||
By default also skips common noise dirs (``node_modules``, ``__pycache__``,
|
||||
``.venv``, ``dist``, …). Pass ``skip_dirs=[]`` to disable the noise list
|
||||
(VCS still skipped). Pass an explicit list to replace the default noise set.
|
||||
|
||||
By default skips other dot-directories (not hidden files). Use
|
||||
``skip_hidden_dirs=false`` to include them.
|
||||
|
||||
``apply_path_denylist`` (default true) hides entries whose full path matches
|
||||
the agent ``path_denylist`` policy.
|
||||
|
||||
``max_depth`` limits how deep directories are expanded (1 = origin + children).
|
||||
``max_entries`` caps how many entries are listed per directory.
|
||||
"""
|
||||
@@ -135,15 +191,18 @@ def tree(
|
||||
|
||||
root_label = path.rstrip("/\\") or "."
|
||||
lines = [f"{root_label}/"]
|
||||
_render_tree(
|
||||
origin,
|
||||
prefix="",
|
||||
depth=0,
|
||||
limits = _TreeLimits(
|
||||
max_depth=max_depth,
|
||||
max_entries=max_entries,
|
||||
skip_hidden_dirs=skip_hidden_dirs,
|
||||
),
|
||||
skip_basenames=_resolve_skip_basenames(skip_dirs),
|
||||
apply_path_denylist=apply_path_denylist,
|
||||
)
|
||||
_render_tree(
|
||||
origin,
|
||||
prefix="",
|
||||
depth=0,
|
||||
limits=limits,
|
||||
lines=lines,
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from .close_pty import close_pty as close_pty
|
||||
from .open_pty import open_pty as open_pty
|
||||
from .pty_terminal import decode_write_data as decode_write_data
|
||||
from .pty_terminal import sanitize_pty_output_for_tool as sanitize_pty_output_for_tool
|
||||
from .read_pty import read_pty as read_pty
|
||||
from .run_command import run_command as run_command
|
||||
from .write_pty import write_pty as write_pty
|
||||
from .write_pty_keys import write_pty_keys as write_pty_keys
|
||||
|
||||
PROCESS_TOOLS = [run_command, open_pty, read_pty, write_pty, close_pty]
|
||||
PROCESS_TOOLS = [run_command, open_pty, read_pty, write_pty, write_pty_keys, close_pty]
|
||||
|
||||
@@ -8,6 +8,7 @@ from threading import Lock
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from plyngent.tools.process.pty_backend import PtyHandle, pty_available, spawn_pty
|
||||
from plyngent.tools.process.pty_terminal import sanitize_pty_output_for_tool
|
||||
from plyngent.tools.workspace import WorkspaceError, check_command_allowed, resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -286,6 +287,8 @@ class PtyManager:
|
||||
truncated = len(data) > max_bytes
|
||||
if truncated:
|
||||
data = data[:max_bytes]
|
||||
# Escape CSI so tool results echoed to the host TTY cannot reprogram it.
|
||||
data = sanitize_pty_output_for_tool(data)
|
||||
budget_exhausted = cls._maybe_raise_budget(session)
|
||||
return PtyReadResult(
|
||||
session_id=session_id,
|
||||
@@ -354,6 +357,8 @@ class PtyManager:
|
||||
with cls._lock:
|
||||
_ = cls._sessions.pop(session_id, None)
|
||||
|
||||
# No host TTY reset: tool-facing read_pty sanitizes CSI so chat echo
|
||||
# cannot reprogram the user terminal.
|
||||
return PtyCloseResult(
|
||||
session_id=session_id,
|
||||
closed=True,
|
||||
@@ -381,6 +386,7 @@ class PtyManager:
|
||||
|
||||
@classmethod
|
||||
def close_all(cls) -> None:
|
||||
"""Close every open session (no host TTY reset)."""
|
||||
with cls._lock:
|
||||
ids = list(cls._sessions.keys())
|
||||
for session_id in ids:
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""PTY payload helpers for agent tools.
|
||||
|
||||
Child PTY I/O stays on the master FD. Tool results are printed on the *user*
|
||||
TTY by the CLI, so any CSI/control bytes in ``read_pty`` data would reprogram
|
||||
the host terminal. We sanitize on the tool boundary instead of resetting the
|
||||
host after close (which flashes the screen on every chat exit).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Match common \xNN / \uNNNN / named ctrl+ / esc sequences in write_pty_keys data.
|
||||
_HEX_BYTE = re.compile(r"\\x([0-9A-Fa-f]{2})")
|
||||
_HEX_U = re.compile(r"\\u([0-9A-Fa-f]{4})")
|
||||
_CTRL = re.compile(r"ctrl\+([a-z@\[\\\]\^_])", re.IGNORECASE)
|
||||
_KEY = re.compile(r"key=(esc|escape|enter|tab|up|down|left|right)", re.IGNORECASE)
|
||||
_SIMPLE = {
|
||||
r"\n": "\n",
|
||||
r"\r": "\r",
|
||||
r"\t": "\t",
|
||||
r"\e": "\x1b",
|
||||
r"\E": "\x1b",
|
||||
r"\0": "\x00",
|
||||
}
|
||||
|
||||
_KEY_MAP = {
|
||||
"esc": "\x1b",
|
||||
"escape": "\x1b",
|
||||
"enter": "\r",
|
||||
"tab": "\t",
|
||||
"up": "\x1b[A",
|
||||
"down": "\x1b[B",
|
||||
"right": "\x1b[C",
|
||||
"left": "\x1b[D",
|
||||
}
|
||||
_SIMPLE_ESCAPE_LEN = 2
|
||||
|
||||
# C0 controls except TAB/LF/CR (those stay for readable logs).
|
||||
_UNSAFE_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
||||
|
||||
|
||||
def sanitize_pty_output_for_tool(data: str) -> str:
|
||||
"""Make PTY bytes safe to print on the host as tool/chat text.
|
||||
|
||||
- ESC becomes the two-character sequence ``\\\\x1b`` so CSI is not executed
|
||||
when the CLI echoes tool results.
|
||||
- Other C0 controls (except tab/LF/CR) become ``\\\\xHH``.
|
||||
"""
|
||||
if not data:
|
||||
return data
|
||||
|
||||
def _esc_ctrl(match: re.Match[str]) -> str:
|
||||
return f"\\x{ord(match.group(0)):02x}"
|
||||
|
||||
# ESC first as a stable, readable form used in docs/tests.
|
||||
out = data.replace("\x1b", "\\x1b")
|
||||
return _UNSAFE_CTRL.sub(_esc_ctrl, out)
|
||||
|
||||
|
||||
def decode_write_data(data: str) -> str:
|
||||
"""Expand agent-friendly escapes into raw PTY input (for ``write_pty_keys`` only).
|
||||
|
||||
Supported (as literal characters in ``data``):
|
||||
|
||||
- ``\\n`` ``\\r`` ``\\t`` ``\\e`` / ``\\E`` (ESC) ``\\0``
|
||||
- ``\\xHH`` byte, ``\\uHHHH`` Unicode code point
|
||||
- ``ctrl+c`` / ``ctrl+x`` ... (case-insensitive; A-Z or @ [ \\ ] ^ _)
|
||||
- ``key=esc`` ``key=enter`` ``key=tab`` ``key=up|down|left|right``
|
||||
"""
|
||||
if not data:
|
||||
return data
|
||||
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
n = len(data)
|
||||
while i < n:
|
||||
rest = data[i:]
|
||||
m_key = _KEY.match(rest)
|
||||
if m_key is not None:
|
||||
out.append(_KEY_MAP[m_key.group(1).lower()])
|
||||
i += m_key.end()
|
||||
continue
|
||||
m_ctrl = _CTRL.match(rest)
|
||||
if m_ctrl is not None:
|
||||
ch = m_ctrl.group(1).upper()
|
||||
out.append(chr((ord(ch) - ord("@")) & 0x1F))
|
||||
i += m_ctrl.end()
|
||||
continue
|
||||
m_hex = _HEX_BYTE.match(rest)
|
||||
if m_hex is not None:
|
||||
out.append(chr(int(m_hex.group(1), 16)))
|
||||
i += m_hex.end()
|
||||
continue
|
||||
m_u = _HEX_U.match(rest)
|
||||
if m_u is not None:
|
||||
out.append(chr(int(m_u.group(1), 16)))
|
||||
i += m_u.end()
|
||||
continue
|
||||
if rest.startswith("\\") and len(rest) >= _SIMPLE_ESCAPE_LEN:
|
||||
pair = rest[:_SIMPLE_ESCAPE_LEN]
|
||||
if pair in _SIMPLE:
|
||||
out.append(_SIMPLE[pair])
|
||||
i += _SIMPLE_ESCAPE_LEN
|
||||
continue
|
||||
out.append(data[i])
|
||||
i += 1
|
||||
return "".join(out)
|
||||
@@ -6,22 +6,31 @@ from plyngent.tools.workspace import WorkspaceError
|
||||
from .pty_session import PtyManager
|
||||
|
||||
|
||||
@tool
|
||||
def write_pty(session_id: int, data: str) -> str:
|
||||
"""Write text to a PTY session (interactive input). Does not append a newline."""
|
||||
try:
|
||||
PtyManager.write(session_id, data)
|
||||
def write_pty_payload(session_id: int, raw: str) -> str:
|
||||
"""Write raw bytes (as str) to the PTY and format the tool status string."""
|
||||
PtyManager.write(session_id, raw)
|
||||
session = PtyManager.refresh(session_id)
|
||||
except WorkspaceError as exc:
|
||||
return f"error: {exc}"
|
||||
except OSError as exc:
|
||||
return f"error: failed to write PTY: {exc}"
|
||||
exit_disp = "" if session.exit_code is None else str(session.exit_code)
|
||||
return "\n".join(
|
||||
[
|
||||
f"session_id={session_id}",
|
||||
f"alive={'true' if session.alive else 'false'}",
|
||||
f"exit_code={exit_disp}",
|
||||
f"wrote={len(data)}",
|
||||
f"wrote={len(raw.encode())}",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def write_pty(session_id: int, data: str) -> str:
|
||||
"""Write **literal** text to a PTY session. Does not append a newline.
|
||||
|
||||
``data`` is sent unchanged (no ``ctrl+x`` / ``\\\\xHH`` expansion). For
|
||||
control sequences use :func:`write_pty_keys`.
|
||||
"""
|
||||
try:
|
||||
return write_pty_payload(session_id, data)
|
||||
except WorkspaceError as exc:
|
||||
return f"error: {exc}"
|
||||
except OSError as exc:
|
||||
return f"error: failed to write PTY: {exc}"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from plyngent.agent import tool
|
||||
from plyngent.tools.workspace import WorkspaceError
|
||||
|
||||
from .pty_terminal import decode_write_data
|
||||
from .write_pty import write_pty_payload
|
||||
|
||||
|
||||
@tool
|
||||
def write_pty_keys(session_id: int, data: str) -> str:
|
||||
"""Write to a PTY after expanding key escapes (never for normal typing).
|
||||
|
||||
Use this only for control sequences. Prefer :func:`write_pty` for plain text
|
||||
so strings like ``press ctrl+c`` are not rewritten.
|
||||
|
||||
Escapes (literal characters in ``data``):
|
||||
|
||||
- ``\\\\n`` ``\\\\r`` ``\\\\t`` ``\\\\e``/``\\\\E`` (ESC) ``\\\\0``
|
||||
- ``\\\\xHH`` byte, ``\\\\uHHHH`` Unicode code point
|
||||
- ``ctrl+c`` / ``ctrl+x`` ... (case-insensitive)
|
||||
- ``key=esc|enter|tab|up|down|left|right``
|
||||
"""
|
||||
try:
|
||||
raw = decode_write_data(data)
|
||||
return write_pty_payload(session_id, raw)
|
||||
except WorkspaceError as exc:
|
||||
return f"error: {exc}"
|
||||
except OSError as exc:
|
||||
return f"error: failed to write PTY: {exc}"
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.agent import tool
|
||||
from plyngent.tools.workspace import (
|
||||
MAX_TEMPORARY_WORKSPACES,
|
||||
WorkspaceError,
|
||||
add_workspace_allowlist,
|
||||
list_workspace_allowlist,
|
||||
pop_owned_temporary_workspaces,
|
||||
remove_workspace_allowlist,
|
||||
)
|
||||
|
||||
_DEFAULT_PREFIX = "ws"
|
||||
_PREFIX_MAX_LEN = 32
|
||||
_PREFIX_SAFE = re.compile(rf"^[A-Za-z0-9_-]{{1,{_PREFIX_MAX_LEN}}}$")
|
||||
|
||||
|
||||
def _sanitize_prefix(prefix: str) -> str:
|
||||
token = (prefix or _DEFAULT_PREFIX).strip() or _DEFAULT_PREFIX
|
||||
# Replace unsafe characters so mkdtemp stays portable on Windows/POSIX.
|
||||
cleaned = re.sub(r"[^A-Za-z0-9_-]+", "-", token)
|
||||
cleaned = cleaned.strip("-_") or _DEFAULT_PREFIX
|
||||
if len(cleaned) > _PREFIX_MAX_LEN:
|
||||
cleaned = cleaned[:_PREFIX_MAX_LEN]
|
||||
if not _PREFIX_SAFE.match(cleaned):
|
||||
return _DEFAULT_PREFIX
|
||||
return cleaned
|
||||
|
||||
|
||||
def _is_under_system_temp(path: Path) -> bool:
|
||||
"""True if *path* is under the OS temporary directory (safe to rmtree)."""
|
||||
try:
|
||||
temp_root = Path(tempfile.gettempdir()).expanduser().resolve()
|
||||
resolved = path.expanduser().resolve()
|
||||
_ = resolved.relative_to(temp_root)
|
||||
except OSError, ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def cleanup_temporary_workspaces() -> int:
|
||||
"""Remove directories created by :func:`new_temporary_workspace` (chat exit).
|
||||
|
||||
Only deletes paths still under the system temp dir. Returns the number of
|
||||
directories removed.
|
||||
"""
|
||||
removed = 0
|
||||
for path in pop_owned_temporary_workspaces():
|
||||
if not _is_under_system_temp(path):
|
||||
remove_workspace_allowlist(path)
|
||||
continue
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
removed += 1
|
||||
remove_workspace_allowlist(path)
|
||||
return removed
|
||||
|
||||
|
||||
@tool
|
||||
def new_temporary_workspace(prefix: str = "ws") -> str:
|
||||
"""Create a scratch directory under the system temp dir and allow tool paths in it.
|
||||
|
||||
The project workspace is unchanged: relative paths still resolve there.
|
||||
Use the returned **absolute** path for file/process tools. Temporary
|
||||
workspaces are deleted when this chat process exits (not on each turn).
|
||||
|
||||
Cross-platform (uses ``tempfile``; typically ``/tmp`` on POSIX, ``%TEMP%``
|
||||
on Windows). At most 16 concurrent temporary workspaces per process.
|
||||
"""
|
||||
if len(list_workspace_allowlist()) >= MAX_TEMPORARY_WORKSPACES:
|
||||
return f"error: too many temporary workspaces (max {MAX_TEMPORARY_WORKSPACES})"
|
||||
|
||||
safe = _sanitize_prefix(prefix)
|
||||
try:
|
||||
# mkdtemp is cross-platform; prefix must end with - for readability.
|
||||
path_str = tempfile.mkdtemp(prefix=f"plyngent-{safe}-")
|
||||
path = Path(path_str).resolve()
|
||||
except OSError as exc:
|
||||
return f"error: failed to create temporary workspace: {exc}"
|
||||
|
||||
try:
|
||||
_ = add_workspace_allowlist(path, owned=True)
|
||||
except WorkspaceError as exc:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
return f"error: {exc}"
|
||||
|
||||
return (
|
||||
f"temporary_workspace={path}\n"
|
||||
"note: project workspace unchanged; use this absolute path for tools; "
|
||||
"removed when chat exits"
|
||||
)
|
||||
@@ -0,0 +1,126 @@
|
||||
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
|
||||
|
||||
_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:
|
||||
"""Show the LIFO stack of **task groups** (TOP group = current breakdown level).
|
||||
|
||||
Push creates one group of siblings; pop removes the whole top group.
|
||||
Pattern: push [T1,T2] → push [T1.1,T1.2] → finish children → pop → push [T2.1]…
|
||||
"""
|
||||
stack = _require_stack()
|
||||
stack.mark_touched()
|
||||
_notify()
|
||||
return stack.render()
|
||||
|
||||
|
||||
@tool(name="todo_push")
|
||||
def todo_push(titles: list[str], notes: str = "") -> str:
|
||||
"""Push **one task group** (siblings) onto the stack — not one level per title.
|
||||
|
||||
``titles``: JSON array of strings (tool schema type ``array``). All entries
|
||||
become members of a single new TOP group. Example: ``[\"T1\", \"T2\"]`` pushes
|
||||
one group {T1, T2}; a later ``[\"T1.1\", \"T1.2\"]`` pushes a child group above it.
|
||||
"""
|
||||
stack = _require_stack()
|
||||
parsed = [t.strip() for t in titles if t and t.strip()]
|
||||
if not parsed:
|
||||
return "error: titles must be a non-empty array of strings"
|
||||
try:
|
||||
group = stack.push_group(parsed, notes=notes)
|
||||
except ValueError as exc:
|
||||
return f"error: {exc}"
|
||||
_notify()
|
||||
ids = ", ".join(i.id for i in group.items)
|
||||
return f"pushed group (depth={stack.depth}) items=[{ids}]\n{stack.render()}"
|
||||
|
||||
|
||||
@tool(name="todo_pop")
|
||||
def todo_pop() -> str:
|
||||
"""Pop the entire **top group** (all siblings from that push)."""
|
||||
stack = _require_stack()
|
||||
group = stack.pop()
|
||||
if group is None:
|
||||
return "todo stack empty"
|
||||
_notify()
|
||||
titles = ", ".join(f"{i.id}:{i.title}" for i in group.items) or "(empty)"
|
||||
top = stack.top_group
|
||||
top_s = "(empty)" if top is None else ", ".join(i.id for i in top.items)
|
||||
return f"popped TOP group ({titles}); new top group=[{top_s}]\n{stack.render()}"
|
||||
|
||||
|
||||
@tool(name="todo_update")
|
||||
def todo_update(
|
||||
item_id: str,
|
||||
status: str = "",
|
||||
title: str = "",
|
||||
notes: str = "",
|
||||
) -> str:
|
||||
"""Update a task by id inside any group. Pop the group when that level is done."""
|
||||
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 all groups on the 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]
|
||||
@@ -26,6 +26,9 @@ DEFAULT_COMMAND_DENYLIST: frozenset[str] = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# Max concurrent temporary workspaces registered in one process.
|
||||
MAX_TEMPORARY_WORKSPACES = 16
|
||||
|
||||
|
||||
class WorkspaceError(ValueError):
|
||||
"""Raised when a path or command violates workspace policy."""
|
||||
@@ -35,6 +38,14 @@ class _WorkspaceState:
|
||||
root: Path | None = None
|
||||
path_denylist: tuple[str, ...] = ()
|
||||
command_denylist: frozenset[str] = DEFAULT_COMMAND_DENYLIST
|
||||
# Extra roots allowed for resolve_path (e.g. temporary workspaces under system temp).
|
||||
allowlist: list[Path]
|
||||
# Paths created by new_temporary_workspace (subset of allowlist); cleaned on chat exit.
|
||||
temporary_owned: list[Path]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.allowlist = []
|
||||
self.temporary_owned = []
|
||||
|
||||
|
||||
_state = _WorkspaceState()
|
||||
@@ -59,7 +70,7 @@ def get_workspace_root() -> Path:
|
||||
|
||||
|
||||
def clear_workspace_root() -> None:
|
||||
"""Clear workspace root (mainly for tests)."""
|
||||
"""Clear workspace root (mainly for tests). Does not clear allowlist."""
|
||||
_state.root = None
|
||||
|
||||
|
||||
@@ -82,18 +93,83 @@ def get_command_denylist() -> frozenset[str]:
|
||||
return _state.command_denylist
|
||||
|
||||
|
||||
def add_workspace_allowlist(root: Path | str, *, owned: bool = False) -> Path:
|
||||
"""Allow tool paths under *root* in addition to the primary workspace.
|
||||
|
||||
When *owned* is true, the path is also registered for chat-exit cleanup
|
||||
(only paths created by :func:`new_temporary_workspace`).
|
||||
"""
|
||||
path = Path(root).expanduser().resolve()
|
||||
if not path.is_dir():
|
||||
msg = f"allowlist root is not a directory: {path}"
|
||||
raise WorkspaceError(msg)
|
||||
if path not in _state.allowlist:
|
||||
if len(_state.allowlist) >= MAX_TEMPORARY_WORKSPACES and owned:
|
||||
msg = f"too many temporary workspaces (max {MAX_TEMPORARY_WORKSPACES})"
|
||||
raise WorkspaceError(msg)
|
||||
_state.allowlist.append(path)
|
||||
if owned and path not in _state.temporary_owned:
|
||||
_state.temporary_owned.append(path)
|
||||
return path
|
||||
|
||||
|
||||
def list_workspace_allowlist() -> list[Path]:
|
||||
"""Return a copy of extra allowed roots (not including the primary workspace)."""
|
||||
return list(_state.allowlist)
|
||||
|
||||
|
||||
def clear_workspace_allowlist() -> None:
|
||||
"""Clear allowlist and owned-temp registry (tests). Does not delete directories."""
|
||||
_state.allowlist.clear()
|
||||
_state.temporary_owned.clear()
|
||||
|
||||
|
||||
def pop_owned_temporary_workspaces() -> list[Path]:
|
||||
"""Return and clear the owned temporary workspace list (for chat-exit cleanup).
|
||||
|
||||
Paths remain on the allowlist until the caller removes them via
|
||||
:func:`remove_workspace_allowlist`.
|
||||
"""
|
||||
owned = list(_state.temporary_owned)
|
||||
_state.temporary_owned.clear()
|
||||
return owned
|
||||
|
||||
|
||||
def remove_workspace_allowlist(root: Path | str) -> None:
|
||||
"""Drop *root* from the allowlist if present."""
|
||||
path = Path(root).expanduser().resolve()
|
||||
while path in _state.allowlist:
|
||||
_state.allowlist.remove(path)
|
||||
|
||||
|
||||
def _under_any_root(resolved: Path) -> bool:
|
||||
roots: list[Path] = []
|
||||
if _state.root is not None:
|
||||
roots.append(_state.root)
|
||||
roots.extend(_state.allowlist)
|
||||
for root in roots:
|
||||
try:
|
||||
_ = resolved.relative_to(root)
|
||||
except ValueError:
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def resolve_path(path: str | Path) -> Path:
|
||||
"""Resolve ``path`` under the workspace root; reject escapes and denylist hits."""
|
||||
"""Resolve ``path`` under the workspace root or an allowlisted temp root.
|
||||
|
||||
Relative paths resolve against the **primary** workspace root. Absolute
|
||||
paths may also land under a temporary workspace allowlist entry.
|
||||
"""
|
||||
root = get_workspace_root()
|
||||
candidate = Path(path)
|
||||
if not candidate.is_absolute():
|
||||
candidate = root / candidate
|
||||
resolved = candidate.expanduser().resolve()
|
||||
try:
|
||||
_ = resolved.relative_to(root)
|
||||
except ValueError as exc:
|
||||
if not _under_any_root(resolved):
|
||||
msg = f"path escapes workspace root ({root}): {path}"
|
||||
raise WorkspaceError(msg) from exc
|
||||
raise WorkspaceError(msg)
|
||||
# Normalize separators so denylist entries like ``/secrets/`` match on Windows.
|
||||
resolved_str = str(resolved).replace("\\", "/")
|
||||
for pattern in _state.path_denylist:
|
||||
|
||||
@@ -30,6 +30,7 @@ from plyngent.lmproto.openai_compatible.model import (
|
||||
ChatCompletionsParam,
|
||||
ChunkChoice,
|
||||
DeltaMessage,
|
||||
FinishReason,
|
||||
StreamFunctionDelta,
|
||||
StreamToolCallDelta,
|
||||
ToolChatMessage,
|
||||
@@ -61,6 +62,28 @@ def _chunks_from_response(response: ChatCompletionResponse) -> list[ChatCompleti
|
||||
],
|
||||
)
|
||||
)
|
||||
# Emit terminal finish_reason when content was streamed without one.
|
||||
fr = response.choices[0].finish_reason
|
||||
if (
|
||||
fr
|
||||
and (isinstance(message.content, str) and message.content)
|
||||
and (message.tool_calls is UNSET or not message.tool_calls)
|
||||
):
|
||||
chunks.append(
|
||||
ChatCompletionChunk(
|
||||
id=response.id,
|
||||
object="chat.completion.chunk",
|
||||
created=response.created,
|
||||
model=response.model,
|
||||
choices=[
|
||||
ChunkChoice(
|
||||
index=0,
|
||||
delta=DeltaMessage(),
|
||||
finish_reason=fr,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
tool_calls = message.tool_calls
|
||||
if tool_calls is not UNSET and tool_calls:
|
||||
deltas: list[StreamToolCallDelta] = []
|
||||
@@ -152,7 +175,8 @@ class ScriptedClient:
|
||||
def _response(
|
||||
message: AssistantChatMessage,
|
||||
*,
|
||||
usage: dict[str, int] | None = None,
|
||||
usage: dict[str, object] | None = None,
|
||||
finish_reason: FinishReason | None = "stop",
|
||||
) -> ChatCompletionResponse:
|
||||
return ChatCompletionResponse(
|
||||
id="1",
|
||||
@@ -164,7 +188,7 @@ def _response(
|
||||
index=0,
|
||||
message=message,
|
||||
logprobs={},
|
||||
finish_reason="stop",
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
],
|
||||
system_fingerprint="",
|
||||
@@ -825,3 +849,118 @@ async def test_tool_result_char_budget() -> None:
|
||||
assert len(tool_msgs) == 1
|
||||
assert tool_msgs[0].content.startswith("x" * 20)
|
||||
assert "truncated" in tool_msgs[0].content
|
||||
|
||||
|
||||
async def test_empty_completion_raises() -> None:
|
||||
client = ScriptedClient([_response(AssistantChatMessage(content=None))])
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
with pytest.raises(RuntimeError, match="empty model completion"):
|
||||
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||
|
||||
|
||||
async def test_length_finish_raises() -> None:
|
||||
client = ScriptedClient(
|
||||
[
|
||||
_response(
|
||||
AssistantChatMessage(content="partial"),
|
||||
finish_reason="length",
|
||||
)
|
||||
]
|
||||
)
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
with pytest.raises(RuntimeError, match="truncated"):
|
||||
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||
|
||||
|
||||
async def test_content_filter_finish_raises() -> None:
|
||||
client = ScriptedClient(
|
||||
[
|
||||
_response(
|
||||
AssistantChatMessage(content=""),
|
||||
finish_reason="content_filter",
|
||||
)
|
||||
]
|
||||
)
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
with pytest.raises(RuntimeError, match="content filter"):
|
||||
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||
|
||||
|
||||
async def test_stream_missing_terminal_empty_raises() -> None:
|
||||
class EmptyStreamClient:
|
||||
@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 param
|
||||
if not stream:
|
||||
return _response(AssistantChatMessage(content="x"))
|
||||
|
||||
async def chunks() -> AsyncIterator[ChatCompletionChunk]:
|
||||
# Usage-only style chunk with no finish_reason and no content.
|
||||
yield ChatCompletionChunk(
|
||||
id="1",
|
||||
object="chat.completion.chunk",
|
||||
created=0,
|
||||
model="t",
|
||||
choices=[],
|
||||
usage={"prompt_tokens": 1, "completion_tokens": 0, "total_tokens": 1},
|
||||
)
|
||||
|
||||
return chunks()
|
||||
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
with pytest.raises(RuntimeError, match="stream ended without a terminal"):
|
||||
_ = [e async for e in run_chat_loop(EmptyStreamClient(), messages, model="m", stream=True)]
|
||||
|
||||
|
||||
async def test_stream_payload_without_finish_reason_ok() -> None:
|
||||
"""Some providers omit finish_reason; non-empty text still counts as terminal."""
|
||||
|
||||
class PayloadNoFinishClient:
|
||||
@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 param
|
||||
if not stream:
|
||||
return _response(AssistantChatMessage(content="ok"))
|
||||
|
||||
async def chunks() -> AsyncIterator[ChatCompletionChunk]:
|
||||
yield ChatCompletionChunk(
|
||||
id="1",
|
||||
object="chat.completion.chunk",
|
||||
created=0,
|
||||
model="t",
|
||||
choices=[
|
||||
ChunkChoice(
|
||||
index=0,
|
||||
delta=DeltaMessage(content="ok"),
|
||||
finish_reason=None,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
return chunks()
|
||||
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
events = [e async for e in run_chat_loop(PayloadNoFinishClient(), messages, model="m", stream=True)]
|
||||
assert any(isinstance(e, TextDeltaEvent) and e.content == "ok" for e in events)
|
||||
|
||||
@@ -8,6 +8,7 @@ from plyngent.agent.responses_bridge import (
|
||||
chat_param_to_responses_kwargs,
|
||||
response_to_assistant_message,
|
||||
response_to_chat_completion,
|
||||
responses_status_to_finish_reason,
|
||||
tool_items_to_response_tools,
|
||||
)
|
||||
from plyngent.agent.responses_client import ResponsesChatClient
|
||||
@@ -100,6 +101,31 @@ def test_response_to_assistant_with_tools() -> None:
|
||||
assert completion.choices[0].message.content == "done"
|
||||
assert isinstance(completion.usage, dict)
|
||||
assert completion.usage["input_tokens"] == 10
|
||||
assert completion.choices[0].finish_reason == "tool_calls"
|
||||
|
||||
|
||||
def test_responses_status_to_finish_reason_incomplete() -> None:
|
||||
body = {
|
||||
"id": "resp_i",
|
||||
"object": "response",
|
||||
"created_at": 1,
|
||||
"model": "gpt-test",
|
||||
"status": "incomplete",
|
||||
"incomplete_details": {"reason": "max_output_tokens"},
|
||||
"output": [
|
||||
{
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "incomplete",
|
||||
"content": [{"type": "output_text", "text": "partial", "annotations": []}],
|
||||
}
|
||||
],
|
||||
}
|
||||
resp = msgspec.convert(body, Response)
|
||||
assert responses_status_to_finish_reason(resp, has_tool_calls=False) == "length"
|
||||
completion = response_to_chat_completion(resp)
|
||||
assert completion.choices[0].finish_reason == "length"
|
||||
|
||||
|
||||
def test_chat_param_to_responses_kwargs() -> None:
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
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, parse_push_titles
|
||||
from plyngent.config.models import DatabaseConfig
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantChatMessage,
|
||||
ChatCompletionChoice,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionResponse,
|
||||
ChatCompletionsParam,
|
||||
DeveloperChatMessage,
|
||||
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_parse_push_titles() -> None:
|
||||
assert parse_push_titles("only") == ["only"]
|
||||
assert parse_push_titles("T1\nT2") == ["T1", "T2"]
|
||||
assert parse_push_titles("T1; T2; T3") == ["T1", "T2", "T3"]
|
||||
assert parse_push_titles('["A", "B"]') == ["A", "B"]
|
||||
|
||||
|
||||
def test_ids_reuse_after_clear() -> None:
|
||||
stack = TodoStack()
|
||||
g = stack.push_group(["A", "B"])
|
||||
assert [i.id for i in g.items] == ["t1", "t2"]
|
||||
assert stack.clear() == 2
|
||||
g2 = stack.push_group(["C"])
|
||||
assert [i.id for i in g2.items] == ["t1"]
|
||||
|
||||
|
||||
def test_ids_reuse_after_pop() -> None:
|
||||
stack = TodoStack()
|
||||
_ = stack.push_group(["A", "B"]) # t1, t2
|
||||
g2 = stack.push_group(["C"]) # t3
|
||||
assert g2.items[0].id == "t3"
|
||||
_ = stack.pop() # drop t3
|
||||
g3 = stack.push_group(["D"])
|
||||
# Highest live id is t2 → next is t3 again
|
||||
assert g3.items[0].id == "t3"
|
||||
|
||||
|
||||
def test_push_is_group_not_per_task_stack() -> None:
|
||||
"""Multi-title push is one group; pop removes the whole group."""
|
||||
stack = TodoStack()
|
||||
g = stack.push_group(["T1", "T2"])
|
||||
assert stack.depth == 1
|
||||
assert [i.title for i in g.items] == ["T1", "T2"]
|
||||
assert stack.top_group is g
|
||||
# Not two stack levels of single tasks
|
||||
assert len(stack.groups) == 1
|
||||
|
||||
g2 = stack.push_group(["T1.1", "T1.2"])
|
||||
assert stack.depth == 2
|
||||
assert [i.title for i in g2.items] == ["T1.1", "T1.2"]
|
||||
|
||||
popped = stack.pop()
|
||||
assert popped is not None
|
||||
assert [i.title for i in popped.items] == ["T1.1", "T1.2"]
|
||||
assert stack.depth == 1
|
||||
assert stack.top_group is not None
|
||||
assert [i.title for i in stack.top_group.items] == ["T1", "T2"]
|
||||
|
||||
|
||||
def test_dfs_breakdown_with_groups() -> None:
|
||||
"""push [T1,T2] → push [T1.1,T1.2] → pop → push [T2.1]."""
|
||||
stack = TodoStack()
|
||||
root = stack.push_group(["T1", "T2"])
|
||||
children = stack.push_group(["T1.1", "T1.2"])
|
||||
assert stack.depth == 2
|
||||
stack.update(children.items[0].id, status="done")
|
||||
stack.update(children.items[1].id, status="done")
|
||||
_ = stack.pop()
|
||||
assert stack.depth == 1
|
||||
stack.update(root.items[0].id, status="done")
|
||||
_ = stack.push_group(["T2.1"])
|
||||
assert stack.depth == 2
|
||||
assert stack.top_group is not None
|
||||
assert stack.top_group.items[0].title == "T2.1"
|
||||
assert stack.groups[0].items[1].title == "T2"
|
||||
|
||||
|
||||
def test_single_title_still_one_group() -> None:
|
||||
stack = TodoStack()
|
||||
item = stack.push("only")
|
||||
assert stack.depth == 1
|
||||
assert item.title == "only"
|
||||
g = stack.pop()
|
||||
assert g is not None and len(g.items) == 1
|
||||
assert stack.is_empty()
|
||||
|
||||
|
||||
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_legacy_flat_and_frames_migrate() -> None:
|
||||
flat = TodoStack.from_raw(
|
||||
{
|
||||
"items": [{"id": "t1", "title": "old", "status": "pending", "notes": ""}],
|
||||
"next_id": 2,
|
||||
}
|
||||
)
|
||||
assert flat.depth == 1
|
||||
assert flat.all_items()[0].title == "old"
|
||||
|
||||
framed = TodoStack.from_raw(
|
||||
{
|
||||
"frames": [
|
||||
{"items": [{"id": "t1", "title": "T1", "status": "pending", "notes": ""}]},
|
||||
{"items": [{"id": "t2", "title": "T1.1", "status": "pending", "notes": ""}]},
|
||||
],
|
||||
"next_id": 3,
|
||||
}
|
||||
)
|
||||
assert framed.depth == 2
|
||||
assert framed.top_group is not None
|
||||
assert framed.top_group.items[0].title == "T1.1"
|
||||
|
||||
|
||||
def test_todo_stack_roundtrip_raw() -> None:
|
||||
stack = TodoStack()
|
||||
_ = stack.push_group(["x", "y"], notes="n")
|
||||
raw = stack.to_raw()
|
||||
assert "groups" in raw
|
||||
restored = TodoStack.from_raw(raw)
|
||||
assert restored.depth == 1
|
||||
assert [i.title for i in restored.groups[0].items] == ["x", "y"]
|
||||
|
||||
|
||||
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))
|
||||
out = await registry.execute("todo_push", '{"titles": ["T1", "T2"]}')
|
||||
assert "group" in out.lower() or "pushed" in out
|
||||
assert stack.depth == 1
|
||||
assert [i.title for i in stack.groups[0].items] == ["T1", "T2"]
|
||||
out = await registry.execute("todo_push", '{"titles": ["T1", "T2"]}')
|
||||
assert stack.depth == 2
|
||||
out3 = await registry.execute("todo_pop", "{}")
|
||||
assert "popped" in out3
|
||||
assert stack.depth == 1
|
||||
_ = 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.depth == 1
|
||||
assert [i.title for i in again.groups[0].items] == ["T1", "T2"]
|
||||
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
|
||||
text = "ok" if self.calls > 1 else "done without todos"
|
||||
for msg in param.messages:
|
||||
if isinstance(msg, DeveloperChatMessage) and "[TODO OPEN]" 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, DeveloperChatMessage) and "[TODO OPEN]" in m.content for m in agent.messages)
|
||||
assert not any(isinstance(m, UserChatMessage) and "[TODO OPEN]" in m.content for m in agent.messages)
|
||||
finally:
|
||||
set_todo_stack(None)
|
||||
@@ -99,11 +99,63 @@ async def test_compact_to_new_session(tmp_path: Path) -> None:
|
||||
assert "compacted goals" in summary
|
||||
loaded = await memory.list_messages(new_id)
|
||||
assert any("compacted goals" in getattr(m, "content", "") for m in loaded)
|
||||
# After compact, RAM matches DB and the persist cursor treats seed as stored.
|
||||
assert len(state.agent.messages) == len(loaded)
|
||||
assert state.agent.persist_from == len(state.agent.messages)
|
||||
# Old session still exists and is listable
|
||||
sessions = await memory.list_sessions(workspace=tmp_path)
|
||||
ids = {s.sid for s in sessions}
|
||||
assert old_id in ids
|
||||
assert new_id in ids
|
||||
|
||||
# Simulate restart: resume compact session, continue without duplicating seed.
|
||||
state2 = ReplState(
|
||||
config=config,
|
||||
memory=memory,
|
||||
workspace=tmp_path,
|
||||
provider_name="local",
|
||||
provider=provider,
|
||||
model="gpt-test",
|
||||
tools_enabled=False,
|
||||
)
|
||||
state2.client = SummaryClient()
|
||||
await state2.resume_session(new_id)
|
||||
assert len(state2.agent.messages) == len(loaded)
|
||||
assert state2.agent.persist_from == len(state2.agent.messages)
|
||||
assert any("compacted goals" in getattr(m, "content", "") for m in state2.agent.messages)
|
||||
# Full original conversation still only on the old session.
|
||||
old_loaded = await memory.list_messages(old_id)
|
||||
assert any(isinstance(m, UserChatMessage) and m.content == "do work" for m in old_loaded)
|
||||
finally:
|
||||
await memory.close()
|
||||
|
||||
|
||||
async def test_rebuild_client_preserves_persist_cursor(tmp_path: Path) -> None:
|
||||
_ = set_workspace_root(tmp_path)
|
||||
memory = await MemoryStore.open(DatabaseConfig())
|
||||
try:
|
||||
provider = OpenAIProvider(access_key_or_token="sk-test")
|
||||
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
|
||||
config.providers = {"local": provider}
|
||||
state = ReplState(
|
||||
config=config,
|
||||
memory=memory,
|
||||
workspace=tmp_path,
|
||||
provider_name="local",
|
||||
provider=provider,
|
||||
model="gpt-test",
|
||||
tools_enabled=False,
|
||||
)
|
||||
state.client = SummaryClient()
|
||||
await state.new_session("s")
|
||||
state.agent.replace_messages(
|
||||
[UserChatMessage(content="hi"), AssistantChatMessage(content="yo")],
|
||||
persisted=True,
|
||||
)
|
||||
assert state.agent.persist_from == 2
|
||||
state.rebuild_client()
|
||||
assert len(state.agent.messages) == 2
|
||||
assert state.agent.persist_from == 2
|
||||
finally:
|
||||
await memory.close()
|
||||
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import signal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.cli.interrupt import (
|
||||
allow_task_cancel,
|
||||
pause_task_cancel_for_prompt,
|
||||
run_in_prompt_thread,
|
||||
set_sigint_reinstall,
|
||||
)
|
||||
from plyngent.cli.limits import prompt_continue_limit, prompt_continue_limit_async
|
||||
from plyngent.cli.retry import run_cancellable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
pass
|
||||
|
||||
|
||||
def test_pause_task_cancel_for_prompt() -> None:
|
||||
@@ -20,6 +26,16 @@ def test_pause_task_cancel_for_prompt() -> None:
|
||||
assert allow_task_cancel() is True
|
||||
|
||||
|
||||
def test_nested_pause_depth() -> None:
|
||||
assert allow_task_cancel() is True
|
||||
with pause_task_cancel_for_prompt():
|
||||
assert allow_task_cancel() is False
|
||||
with pause_task_cancel_for_prompt():
|
||||
assert allow_task_cancel() is False
|
||||
assert allow_task_cancel() is False
|
||||
assert allow_task_cancel() is True
|
||||
|
||||
|
||||
def test_prompt_continue_limit_under_pause(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def _confirm(*_a: object, **_k: object) -> bool:
|
||||
assert allow_task_cancel() is False
|
||||
@@ -36,7 +52,6 @@ async def test_run_in_prompt_thread_pauses_cancel() -> None:
|
||||
def work() -> str:
|
||||
return "ok"
|
||||
|
||||
# ContextVar may not propagate to worker threads; assert pause around the call.
|
||||
result = await run_in_prompt_thread(work)
|
||||
assert result == "ok"
|
||||
assert allow_task_cancel() is True
|
||||
@@ -48,3 +63,53 @@ async def test_prompt_continue_limit_async(monkeypatch: pytest.MonkeyPatch) -> N
|
||||
|
||||
monkeypatch.setattr("click.confirm", _confirm)
|
||||
assert await prompt_continue_limit_async("too many rounds") is True
|
||||
|
||||
|
||||
async def test_sigint_cancels_after_prompt_pause() -> None:
|
||||
"""Regression: after a mid-turn prompt, SIGINT must still cancel the turn.
|
||||
|
||||
Previously, reinstalling the asyncio SIGINT handler while
|
||||
allow_task_cancel was still False froze that value into the callback
|
||||
forever (ContextVar snapshot at add_signal_handler).
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
loop.add_signal_handler(signal.SIGINT, lambda: None)
|
||||
loop.remove_signal_handler(signal.SIGINT)
|
||||
except NotImplementedError, RuntimeError, ValueError:
|
||||
pytest.skip("asyncio signal handlers not available on this platform")
|
||||
|
||||
started = asyncio.Event()
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def hang() -> None:
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.sleep(3600)
|
||||
except asyncio.CancelledError:
|
||||
cancelled.set()
|
||||
raise
|
||||
|
||||
async def turn() -> None:
|
||||
# Simulate soft-confirm pause mid-turn, then keep streaming.
|
||||
with pause_task_cancel_for_prompt():
|
||||
assert allow_task_cancel() is False
|
||||
assert allow_task_cancel() is True
|
||||
await hang()
|
||||
|
||||
task = asyncio.create_task(run_cancellable(turn()))
|
||||
await started.wait()
|
||||
# Give run_cancellable a tick to install the handler after the pause reinstall.
|
||||
await asyncio.sleep(0)
|
||||
assert allow_task_cancel() is True
|
||||
# Deliver SIGINT the same way Ctrl+C does under asyncio.
|
||||
loop.call_soon(lambda: None) # ensure loop is processing
|
||||
# Invoke the process signal: raise SIGINT to this process.
|
||||
import os
|
||||
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert cancelled.is_set()
|
||||
# Cleanup any leftover reinstall hook from run_cancellable
|
||||
set_sigint_reinstall(None)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from plyngent.cli.limits import install_cli_limit_hooks, prompt_confirm_tool, prompt_continue_limit
|
||||
from plyngent.cli.limits import (
|
||||
format_tool_confirm_box,
|
||||
install_cli_limit_hooks,
|
||||
prompt_confirm_tool,
|
||||
prompt_continue_limit,
|
||||
)
|
||||
from plyngent.prompting import get_prompt_backend, temporary_backend
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
from tests.test_prompting import ScriptedBackend
|
||||
@@ -24,6 +29,17 @@ def test_prompt_confirm_tool_default_deny() -> None:
|
||||
assert prompt_confirm_tool("delete_path", {"path": "x"}, "delete path 'x'") is False
|
||||
|
||||
|
||||
def test_format_tool_confirm_box_multiline() -> None:
|
||||
reason = "run_command: python3 -c (review code before allow)\nargv:\n python3 -c print(1)\n-c code:\n print(1)"
|
||||
box = format_tool_confirm_box("run_command", reason)
|
||||
assert "┌" in box and "└" in box
|
||||
assert "confirm · tool 'run_command'" in box
|
||||
assert "python3 -c" in box
|
||||
assert "print(1)" in box
|
||||
# Distinct lines, not one jammed row
|
||||
assert box.count("\n") >= 4
|
||||
|
||||
|
||||
def test_install_cli_limit_hooks() -> None:
|
||||
install_cli_limit_hooks()
|
||||
assert callable(getattr(PtyManager, "_limit_continue", None))
|
||||
|
||||
@@ -183,3 +183,20 @@ def test_complete_slash_args_from_registry(tmp_path: object) -> None:
|
||||
assert complete_slash_args(state, "/help", "st") == ["status", "stream"]
|
||||
assert complete_slash_args(state, "/yolo", "") == ["on", "off", "once"]
|
||||
assert complete_slash_args(state, "/yolo", "o") == ["on", "off", "once"]
|
||||
assert "push" in complete_slash_args(state, "/todos", "p")
|
||||
assert "pop" in complete_slash_args(state, "/todos", "p")
|
||||
assert "--persist" in complete_slash_args(state, "/model", "--p")
|
||||
assert "--full" in complete_slash_args(state, "/history", "--f")
|
||||
assert "32" in complete_slash_args(state, "/rounds", "3")
|
||||
|
||||
from plyngent.agent.todo_stack import TodoStack
|
||||
|
||||
state.todo_stack = TodoStack()
|
||||
_ = state.todo_stack.push_group(["alpha", "beta"])
|
||||
ids = complete_slash_args(state, "/todos", "t", prior_args=["done"])
|
||||
assert "t1" in ids
|
||||
assert "t2" in ids
|
||||
|
||||
state.remember_session_ids([3, 12, 40])
|
||||
assert complete_slash_args(state, "/resume", "1") == ["12"]
|
||||
assert complete_slash_args(state, "/delete", "4") == ["40"]
|
||||
|
||||
@@ -109,8 +109,10 @@ async def test_help_command_usage_line(state: ReplState, capsys: pytest.CaptureF
|
||||
async def test_help_history_no_fake_options(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert await handle_slash(state, "/help history") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "Usage: /history [N]" in out
|
||||
assert "Options:" not in out
|
||||
assert "Usage: /history" in out
|
||||
# Real flags for full/preview are documented; Click's meta --help is not.
|
||||
assert "--full" in out
|
||||
assert "--preview" in out
|
||||
assert " --help" not in out
|
||||
|
||||
|
||||
@@ -341,6 +343,36 @@ async def test_verbose_toggle(state: ReplState) -> None:
|
||||
assert get_verbose_tool_results() is False
|
||||
|
||||
|
||||
async def test_model_persist_writes_config(
|
||||
state: ReplState, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
# Seed a TOML-backed store so write() has a real path.
|
||||
path = tmp_path / "plyngent.toml"
|
||||
_ = path.write_text(
|
||||
"""
|
||||
[providers.local]
|
||||
preset = "openai"
|
||||
access_key_or_token = "sk-test"
|
||||
|
||||
[providers.local.models.gpt-test]
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
from plyngent.config import load as load_config
|
||||
|
||||
state.config = load_config(path)
|
||||
state.provider_name = "local"
|
||||
state.provider = state.config.providers["local"]
|
||||
state.model = "gpt-new-id"
|
||||
assert await handle_slash(state, "/model --persist") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "persisted model" in out
|
||||
assert "gpt-new-id" in out
|
||||
state.config.reload()
|
||||
assert "gpt-new-id" in state.config.providers["local"].models
|
||||
assert "gpt-test" in state.config.providers["local"].models
|
||||
|
||||
|
||||
async def test_yolo_toggle(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert state.effective_yolo() == "off"
|
||||
assert state.soft_confirm_enabled() is True
|
||||
@@ -414,10 +446,61 @@ async def test_history(state: ReplState, capsys: pytest.CaptureFixture[str]) ->
|
||||
]
|
||||
assert await handle_slash(state, "/history") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "mode=preview" in out
|
||||
assert "user: hello" in out
|
||||
assert "assistant: hi there" in out
|
||||
|
||||
|
||||
async def test_history_last_full(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage
|
||||
|
||||
long_body = "# Title\n\n" + ("paragraph text. " * 20)
|
||||
state.agent.messages = [
|
||||
UserChatMessage(content="hello"),
|
||||
AssistantChatMessage(content=long_body),
|
||||
]
|
||||
assert await handle_slash(state, "/history last") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "mode=full" in out
|
||||
assert "assistant:" in out
|
||||
# Full mode should not use the 200-char preview ellipsis on the body alone
|
||||
assert long_body[:50] in out or "Title" in out
|
||||
|
||||
|
||||
async def test_history_one_full(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
from plyngent.lmproto.openai_compatible.model import UserChatMessage
|
||||
|
||||
state.agent.messages = [UserChatMessage(content="only me")]
|
||||
assert await handle_slash(state, "/history 1") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "mode=full" in out
|
||||
assert "only me" in out
|
||||
|
||||
|
||||
async def test_history_one_preview_flag(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
from plyngent.lmproto.openai_compatible.model import UserChatMessage
|
||||
|
||||
state.agent.messages = [UserChatMessage(content="x" * 250)]
|
||||
assert await handle_slash(state, "/history 1 --preview") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "mode=preview" in out
|
||||
assert "…" in out
|
||||
|
||||
|
||||
async def test_history_full_flag(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage
|
||||
|
||||
body = "full " * 100
|
||||
state.agent.messages = [
|
||||
UserChatMessage(content="u"),
|
||||
AssistantChatMessage(content=body),
|
||||
]
|
||||
assert await handle_slash(state, "/history 2 --full") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "mode=full" in out
|
||||
assert body[:40] in out
|
||||
|
||||
|
||||
async def test_rounds(state: ReplState) -> None:
|
||||
assert await handle_slash(state, "/rounds 40") is True
|
||||
assert state.max_rounds == 40
|
||||
|
||||
@@ -179,6 +179,39 @@ def test_write_new_config() -> None:
|
||||
assert config.providers["foo2"].access_key_or_token == "sk-00301212"
|
||||
|
||||
|
||||
def test_ensure_model_and_merge_models(tmp_path: Path) -> None:
|
||||
path = tmp_path / "models-persist.toml"
|
||||
_ = path.write_text(
|
||||
"""
|
||||
[providers.local]
|
||||
preset = "openai-compatible"
|
||||
access_key_or_token = "sk-test"
|
||||
url = "https://example/v1"
|
||||
|
||||
[providers.local.models.base]
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = plyngent.config.load(path)
|
||||
assert "base" in config.providers["local"].models
|
||||
assert "extra" not in config.providers["local"].models
|
||||
|
||||
provider = config.ensure_model("local", "extra")
|
||||
assert "extra" in provider.models
|
||||
assert "base" in provider.models
|
||||
# idempotent
|
||||
_ = config.ensure_model("local", "extra")
|
||||
config.write()
|
||||
config.reload()
|
||||
assert set(config.providers["local"].models) == {"base", "extra"}
|
||||
assert config.providers["local"].access_key_or_token == "sk-test"
|
||||
|
||||
_ = config.merge_models("local", ["extra", "remote-a", "remote-b"])
|
||||
config.write()
|
||||
config.reload()
|
||||
assert set(config.providers["local"].models) == {"base", "extra", "remote-a", "remote-b"}
|
||||
|
||||
|
||||
def test_update_config() -> None:
|
||||
file = Path(__file__).parent / "plyngent-edit-2.toml"
|
||||
_ = shutil.copy(Path(__file__).parent / "plyngent-valid.toml", file)
|
||||
|
||||
@@ -4,7 +4,11 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.tools.workspace import clear_workspace_root, set_workspace_root
|
||||
from plyngent.tools.workspace import (
|
||||
clear_workspace_allowlist,
|
||||
clear_workspace_root,
|
||||
set_workspace_root,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
@@ -14,6 +18,8 @@ if TYPE_CHECKING:
|
||||
@pytest.fixture
|
||||
def workspace(tmp_path: Path) -> Iterator[Path]:
|
||||
clear_workspace_root()
|
||||
clear_workspace_allowlist()
|
||||
root = set_workspace_root(tmp_path)
|
||||
yield root
|
||||
clear_workspace_allowlist()
|
||||
clear_workspace_root()
|
||||
|
||||
@@ -11,18 +11,71 @@ def test_classify_delete_and_move() -> None:
|
||||
assert "move" in (classify_danger("move_path", {"src": "a", "dst": "b"}) or "")
|
||||
|
||||
|
||||
def test_classify_copy_overwrite_only() -> None:
|
||||
assert classify_danger("copy_path", {"src": "a", "dst": "b"}) is None
|
||||
assert "overwrite" in (classify_danger("copy_path", {"src": "a", "dst": "b", "overwrite": True}) or "")
|
||||
def test_classify_copy() -> None:
|
||||
assert "copy" in (classify_danger("copy_path", {"src": "a", "dst": "b"}) or "")
|
||||
|
||||
|
||||
def test_classify_write_file_overwrite(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
_ = (workspace / "x.txt").write_text("old", encoding="utf-8")
|
||||
assert "overwrite" in (classify_danger("write_file", {"path": "x.txt", "content": "n"}) or "")
|
||||
assert classify_danger("write_file", {"path": "new.txt", "content": "n"}) is None
|
||||
def test_classify_write_and_edits(tmp_path: Path) -> None:
|
||||
from plyngent.tools.workspace import clear_workspace_root, set_workspace_root
|
||||
|
||||
set_workspace_root(tmp_path)
|
||||
try:
|
||||
reason = classify_danger("write_file", {"path": "x.txt"})
|
||||
assert reason is not None and "write file" in reason
|
||||
assert "edit" in (classify_danger("edit_replace", {"path": "x.txt"}) or "")
|
||||
assert "lines" in (classify_danger("edit_lineno", {"path": "x.txt", "start_line": 1, "end_line": 2}) or "")
|
||||
finally:
|
||||
clear_workspace_root()
|
||||
|
||||
|
||||
def test_classify_safe_tools() -> None:
|
||||
assert classify_danger("read_file", {"path": "a"}) is None
|
||||
assert classify_danger("listdir", {"path": "."}) is None
|
||||
assert classify_danger("run_command", {"command": ["echo", "hi"]}) is None
|
||||
assert classify_danger("open_pty", {"command": ["true"]}) is None
|
||||
assert classify_danger("run_command", {"command": ["ls", "-la"]}) is None
|
||||
|
||||
|
||||
def test_classify_shell_and_dash_c() -> None:
|
||||
r = classify_danger("run_command", {"command": ["bash", "-c", "rm -rf /"]})
|
||||
assert r is not None
|
||||
assert "bash -c" in r
|
||||
assert "rm -rf" in r
|
||||
|
||||
r2 = classify_danger("run_command", {"command": ["python3", "-c", "print(1)"]})
|
||||
assert r2 is not None
|
||||
assert "python" in r2
|
||||
assert "-c" in r2
|
||||
assert "print(1)" in r2
|
||||
|
||||
r_py = classify_danger("run_command", {"command": ["python", "-c", "import os"]})
|
||||
assert r_py is not None and "python -c" in r_py
|
||||
|
||||
r3 = classify_danger("open_pty", {"command": ["bash"]})
|
||||
assert r3 is not None and "bash" in r3
|
||||
assert "interactive" in r3 or "review" in r3
|
||||
|
||||
r4 = classify_danger("open_pty", {"command": ["python3"]})
|
||||
assert r4 is not None and "python" in r4
|
||||
|
||||
r5 = classify_danger("open_pty", {"command": ["python3", "-c", "x=1"]})
|
||||
assert r5 is not None and "-c" in r5
|
||||
|
||||
|
||||
async def test_confirm_deny_with_comment() -> None:
|
||||
from plyngent.agent.tools import ToolRegistry, tool
|
||||
from plyngent.tools.danger import classify_danger as danger
|
||||
|
||||
@tool
|
||||
def delete_path(path: str) -> str:
|
||||
return f"deleted {path}"
|
||||
|
||||
async def deny_comment(name: str, args: object, reason: str) -> str:
|
||||
del name, args, reason
|
||||
return "too destructive for this session"
|
||||
|
||||
reg = ToolRegistry([delete_path], danger=danger, on_confirm=deny_comment)
|
||||
out = await reg.execute("delete_path", '{"path": "x"}')
|
||||
assert "denied" in out
|
||||
assert "user comment:" in out
|
||||
assert "too destructive" in out
|
||||
|
||||
@@ -35,6 +35,18 @@ def test_read_offset_limit(workspace: object) -> None:
|
||||
assert call_sync(read_file, "lines.txt", offset=1, limit=2) == "b\nc\n"
|
||||
|
||||
|
||||
def test_read_with_lineno(workspace: object) -> None:
|
||||
del workspace
|
||||
_ = call_sync(write_file, "num.txt", "a\nb\nc\n")
|
||||
out = call_sync(read_file, "num.txt", with_lineno=True)
|
||||
assert " 1|a\n" in out
|
||||
assert " 2|b\n" in out
|
||||
assert " 3|c\n" in out
|
||||
# offset is 0-based; line numbers stay absolute 1-based file lines
|
||||
mid = call_sync(read_file, "num.txt", offset=1, limit=1, with_lineno=True)
|
||||
assert mid == " 2|b\n"
|
||||
|
||||
|
||||
def test_listdir_missing(workspace: object) -> None:
|
||||
del workspace
|
||||
assert "error" in call_sync(listdir, "nope")
|
||||
|
||||
@@ -4,7 +4,14 @@ import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.tools.process import close_pty, open_pty, read_pty, run_command, write_pty
|
||||
from plyngent.tools.process import (
|
||||
close_pty,
|
||||
open_pty,
|
||||
read_pty,
|
||||
run_command,
|
||||
write_pty,
|
||||
write_pty_keys,
|
||||
)
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
from plyngent.tools.workspace import set_command_denylist
|
||||
from tests.test_tools.helpers import call_async, call_sync
|
||||
@@ -289,6 +296,85 @@ def test_pty_master_not_inheritable(workspace: object) -> None:
|
||||
PtyManager.close_all()
|
||||
|
||||
|
||||
def test_decode_write_data_escapes() -> None:
|
||||
from plyngent.tools.process.pty_terminal import decode_write_data
|
||||
|
||||
assert decode_write_data(r"\x0f") == "\x0f"
|
||||
assert decode_write_data("ctrl+x") == "\x18"
|
||||
assert decode_write_data("CTRL+O") == "\x0f"
|
||||
assert decode_write_data(r"\e") == "\x1b"
|
||||
assert decode_write_data("key=esc") == "\x1b"
|
||||
assert decode_write_data("key=enter") == "\r"
|
||||
assert decode_write_data(r"a\nb") == "a\nb"
|
||||
assert decode_write_data("plain") == "plain"
|
||||
|
||||
|
||||
def test_sanitize_pty_output_escapes_csi() -> None:
|
||||
from plyngent.tools.process.pty_terminal import sanitize_pty_output_for_tool
|
||||
|
||||
raw = chr(0x1B) + "[?1049hhello" + chr(0x1B) + "[0m" + chr(7)
|
||||
safe = sanitize_pty_output_for_tool(raw)
|
||||
assert chr(0x1B) not in safe
|
||||
assert "\\x1b" in safe
|
||||
assert "hello" in safe
|
||||
assert "\\x07" in safe
|
||||
|
||||
|
||||
def test_close_all_empty_is_noop() -> None:
|
||||
"""Ctrl-D / chat exit with no sessions must not touch the host TTY."""
|
||||
PtyManager.close_all()
|
||||
|
||||
|
||||
async def test_read_pty_sanitizes_esc(workspace: object) -> None:
|
||||
del workspace
|
||||
try:
|
||||
opened = call_sync(open_pty, _py("print(chr(0x1B)+'[31mred'+chr(0x1B)+'[0m')"))
|
||||
session_id = _session_id(opened)
|
||||
text = await call_async(read_pty, session_id, timeout=2.0, until="red")
|
||||
assert "red" in text
|
||||
payload = text.split("--- data ---", 1)[-1]
|
||||
assert chr(0x1B) not in payload
|
||||
assert "\\x1b" in payload or "red" in payload
|
||||
_ = await call_async(close_pty, session_id)
|
||||
finally:
|
||||
PtyManager.close_all()
|
||||
|
||||
|
||||
async def test_write_pty_keys_ctrl_escape(workspace: object) -> None:
|
||||
del workspace
|
||||
try:
|
||||
opened = call_sync(
|
||||
open_pty,
|
||||
_py(
|
||||
"import sys\n"
|
||||
"data = sys.stdin.buffer.read(1)\n"
|
||||
"sys.stdout.buffer.write(data)\n"
|
||||
"sys.stdout.buffer.flush()\n"
|
||||
),
|
||||
)
|
||||
session_id = _session_id(opened)
|
||||
written = call_sync(write_pty_keys, session_id, "ctrl+c")
|
||||
assert "wrote=1" in written
|
||||
text = await call_async(read_pty, session_id, timeout=2.0)
|
||||
assert "error" not in text.lower() or "alive=" in text
|
||||
_ = await call_async(close_pty, session_id)
|
||||
finally:
|
||||
PtyManager.close_all()
|
||||
|
||||
|
||||
def test_write_pty_is_literal() -> None:
|
||||
"""Plain write_pty must not call the keys decoder."""
|
||||
import inspect
|
||||
|
||||
from plyngent.tools.process.pty_terminal import decode_write_data
|
||||
|
||||
assert decode_write_data("press ctrl+c to cancel") == "press " + chr(3) + " to cancel"
|
||||
src = inspect.getsource(write_pty.handler)
|
||||
assert "decode_write_data" not in src
|
||||
doc = write_pty.description or write_pty.handler.__doc__ or ""
|
||||
assert "literal" in doc.lower()
|
||||
|
||||
|
||||
def test_pty_backend_available() -> None:
|
||||
from plyngent.tools.process.pty_backend import pty_available
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.tools.file import read_file, write_file
|
||||
from plyngent.tools.temp_workspace import cleanup_temporary_workspaces, new_temporary_workspace
|
||||
from plyngent.tools.workspace import (
|
||||
WorkspaceError,
|
||||
clear_workspace_allowlist,
|
||||
list_workspace_allowlist,
|
||||
resolve_path,
|
||||
)
|
||||
from tests.test_tools.helpers import call_sync
|
||||
|
||||
|
||||
def _temp_path_from_tool_output(out: str) -> Path:
|
||||
line = next(part for part in out.splitlines() if part.startswith("temporary_workspace="))
|
||||
return Path(line.split("=", 1)[1].strip())
|
||||
|
||||
|
||||
def test_new_temporary_workspace_allowlist(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
out = call_sync(new_temporary_workspace, "unit")
|
||||
assert "temporary_workspace=" in out
|
||||
assert "project workspace unchanged" in out
|
||||
temp = _temp_path_from_tool_output(out)
|
||||
assert temp.is_dir()
|
||||
assert temp in list_workspace_allowlist()
|
||||
|
||||
# Absolute path under temp is allowed; project relative still works.
|
||||
target = temp / "scratch.txt"
|
||||
_ = target.write_text("hello-temp", encoding="utf-8")
|
||||
assert resolve_path(str(target)) == target.resolve()
|
||||
assert call_sync(read_file, str(target)) == "hello-temp"
|
||||
|
||||
_ = call_sync(write_file, "project.txt", "proj")
|
||||
assert call_sync(read_file, "project.txt") == "proj"
|
||||
|
||||
# Sibling under system temp that we did not allowlist still fails.
|
||||
outside = temp.parent / "not-ours-should-fail"
|
||||
with pytest.raises(WorkspaceError, match="escapes"):
|
||||
_ = resolve_path(str(outside))
|
||||
|
||||
|
||||
def test_cleanup_removes_owned_temps(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
out = call_sync(new_temporary_workspace)
|
||||
temp = _temp_path_from_tool_output(out)
|
||||
assert temp.is_dir()
|
||||
n = cleanup_temporary_workspaces()
|
||||
assert n >= 1
|
||||
assert not temp.exists()
|
||||
assert list_workspace_allowlist() == []
|
||||
|
||||
|
||||
def test_prefix_sanitized(workspace: object) -> None:
|
||||
del workspace
|
||||
out = call_sync(new_temporary_workspace, "bad/../x y!")
|
||||
assert "temporary_workspace=" in out
|
||||
assert not out.startswith("error")
|
||||
_ = cleanup_temporary_workspaces()
|
||||
clear_workspace_allowlist()
|
||||
@@ -83,3 +83,58 @@ def test_tree_invalid_limits(workspace: object) -> None:
|
||||
del workspace
|
||||
assert "max_depth" in call_sync(tree, ".", max_depth=0)
|
||||
assert "max_entries" in call_sync(tree, ".", max_entries=0)
|
||||
|
||||
|
||||
def test_tree_default_noise_dirs(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
(workspace / "src").mkdir()
|
||||
_ = (workspace / "src" / "app.py").write_text("x", encoding="utf-8")
|
||||
(workspace / "node_modules").mkdir()
|
||||
_ = (workspace / "node_modules" / "pkg.js").write_text("x", encoding="utf-8")
|
||||
(workspace / "__pycache__").mkdir()
|
||||
_ = (workspace / "__pycache__" / "x.pyc").write_text("x", encoding="utf-8")
|
||||
out = call_sync(tree, ".")
|
||||
assert "src/" in out
|
||||
assert "app.py" in out
|
||||
assert "node_modules" not in out
|
||||
assert "__pycache__" not in out
|
||||
|
||||
|
||||
def test_tree_skip_dirs_empty_shows_noise(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
(workspace / "node_modules").mkdir()
|
||||
_ = (workspace / "node_modules" / "pkg.js").write_text("x", encoding="utf-8")
|
||||
out = call_sync(tree, ".", skip_dirs=[])
|
||||
assert "node_modules/" in out
|
||||
assert "pkg.js" in out
|
||||
|
||||
|
||||
def test_tree_skip_dirs_custom(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
(workspace / "keep_me").mkdir()
|
||||
_ = (workspace / "keep_me" / "a.txt").write_text("x", encoding="utf-8")
|
||||
(workspace / "drop_me").mkdir()
|
||||
_ = (workspace / "drop_me" / "b.txt").write_text("x", encoding="utf-8")
|
||||
# Custom list replaces default noise set (node_modules not in list → would show if present).
|
||||
out = call_sync(tree, ".", skip_dirs=["drop_me"])
|
||||
assert "keep_me/" in out
|
||||
assert "drop_me" not in out
|
||||
|
||||
|
||||
def test_tree_path_denylist_walk(workspace: object) -> None:
|
||||
from plyngent.tools.workspace import set_path_denylist
|
||||
|
||||
assert isinstance(workspace, Path)
|
||||
(workspace / "ok").mkdir()
|
||||
_ = (workspace / "ok" / "a.txt").write_text("x", encoding="utf-8")
|
||||
(workspace / "secrets").mkdir()
|
||||
_ = (workspace / "secrets" / "key.txt").write_text("x", encoding="utf-8")
|
||||
set_path_denylist(["/secrets"])
|
||||
try:
|
||||
out = call_sync(tree, ".")
|
||||
assert "ok/" in out
|
||||
assert "secrets" not in out
|
||||
out2 = call_sync(tree, ".", apply_path_denylist=False, skip_dirs=[])
|
||||
assert "secrets/" in out2
|
||||
finally:
|
||||
set_path_denylist(None)
|
||||
|
||||
Reference in New Issue
Block a user