35 Commits

Author SHA1 Message Date
NCBM d473cd2508 project: bump version to 0.1.2 2026-07-18 19:43:33 +08:00
NCBM badcb3c9f3 core/cli: fix Ctrl+C after mid-turn prompts (SIGINT cancel)
asyncio signal handlers freeze ContextVars at install time; reinstalling
while allow_task_cancel was still False left cancel permanently disabled.
Use a process-level prompt depth counter and reinstall only after depth=0.
2026-07-18 19:31:40 +08:00
NCBM fc74b271bd core/cli: richer Tab completion for slash commands
Complete /todos actions and todo ids, session ids for /resume|/delete
(from cache after /sessions), /rounds hints, and --flags. Pass prior
tokens so multi-arg completion works.
2026-07-18 19:04:45 +08:00
NCBM 1012894f0d core/cli: multi-line boxed tool confirm prompts
Echo a terminal-width box with wrapped reason (argv / -c code) before a
short Allow? y/N prompt so shell confirms are not jammed on one line.
2026-07-18 18:41:00 +08:00
NCBM 020e9215bf tools: new_temporary_workspace with cross-platform allowlist
Create scratch dirs under system temp (tempfile), allow absolute paths
under them without rebinding the project workspace or session. Cleanup
owned temps on chat exit. Cap 16 concurrent temps; sanitize prefix.
2026-07-18 18:16:06 +08:00
NCBM de79dec96f tools/file: add with_lineno option to read_file
When true, prefix each returned line with absolute 1-based line numbers
(N|…) for edit_lineno-friendly reads; works with offset/limit.
2026-07-18 17:52:25 +08:00
NCBM b1a8c23582 core/tools: soft-confirm shells, REPLs, and python/bash -c one-liners
classify_danger prompts on interactive shell/runtime launches and any -c
code (with argv + snippet preview). Confirm deny can attach a user comment
for the model. Restores ToolRegistry.execute after WIP breakage.
2026-07-18 17:41:44 +08:00
NCBM 9a9c808ea0 core/agent: agent-facing [TODO OPEN] review nag 2026-07-18 16:48:20 +08:00
NCBM edd54a427c core/tools/process: drop host TTY restore; rely on CSI sanitize only 2026-07-18 16:26:23 +08:00
NCBM 896dc98d20 core/tools/process: skip host TTY restore when no PTY closed 2026-07-18 16:14:19 +08:00
NCBM c8a0aeda8d core/agent: reuse todo ids after clear/pop; todo_push titles as array 2026-07-18 16:09:09 +08:00
NCBM 29230af911 core/tools/process: split write_pty (literal) vs write_pty_keys 2026-07-18 15:46:11 +08:00
NCBM 4ff03d8135 core/tools/process: host TTY restore, sanitize read_pty CSI, write_pty key escapes 2026-07-18 15:20:36 +08:00
NCBM a7166d863b core/agent: todo stack is LIFO of task groups, not per-task push
push(titles) creates one group of siblings; pop removes the whole top
group. Matches breakdown: push[T1,T2] → push[T1.1,T1.2] → pop → push[T2.1].
2026-07-18 14:47:38 +08:00
NCBM 9b38a3b333 ci/lint: prek commit gateway (ruff check/format + basedpyright)
Add prek.toml so git commit runs ruff-pre-commit (fix then format) and
pdm-based basedpyright. Document install/install-hook; clear type warnings
that made basedpyright exit non-zero under recommended mode.
2026-07-18 14:10:04 +08:00
NCBM 39f9a7f636 ci/lint: ruff format todo stack and related CLI/agent files 2026-07-18 13:22:42 +08:00
NCBM 7f49c47105 core/agent: todo is a real LIFO stack (not a queue or frame list)
TOP = last element only for pop; multi-push puts first title on top for DFS
breakdown. Dropped nested-frame model; migrate old frames JSON to flat stack.
2026-07-18 13:20:37 +08:00
NCBM c95d1df028 core/agent: nested todo frames for hierarchical push/pop breakdown
push of one or more titles opens a new frame (siblings at one depth);
pop leaves the frame. Supports T1,T2 → T1.1,T1.2 → pop → T2.1 pattern.
Legacy flat session JSON migrates to a single root frame.
2026-07-18 13:15:57 +08:00
NCBM fb5b9f187b core/cli: /history last and --full with Rich markdown 2026-07-18 13:02:46 +08:00
NCBM abcb345c0d core/agent: todo review nag as developer role, not fake user
Internal control uses DeveloperChatMessage so retry/history treat it as
non-human. Responses bridge maps developer mid-turn; compact/history label it.
2026-07-18 12:27:15 +08:00
NCBM 596af1ea1b core/agent+tools: session todo/task stack with review nag
Sub-task stack stored on session.todo_stack; model tools todo_*; slash
/todos for human ops; agent loop injects a review user message when open
todos exist and no todo_* tool ran this turn. Stack carries across /compact.
2026-07-18 01:59:13 +08:00
NCBM 57236937eb docs: model --persist and tree skip_dirs filtering 2026-07-18 01:32:06 +08:00
NCBM 96cdd98e62 tools/file: tree skip_dirs noise defaults and path_denylist walk
Default noise basenames (node_modules, __pycache__, …); skip_dirs=[] disables;
apply_path_denylist hides denied children mid-tree.
2026-07-18 01:32:06 +08:00
NCBM 2368e46efd core/config+cli: /model --persist and /models --persist to TOML
ensure_model / merge_models update the provider catalog in memory; --persist
writes plyngent.toml so next launch has a warmer model list.
2026-07-18 01:32:06 +08:00
NCBM 65d248064e core/agent: classify empty/truncated/missing stream terminals as errors 2026-07-18 00:56:45 +08:00
NCBM e04fda4bc6 core/agent+cli: fix compact session restore and persist cursor
After /compact, reload seed from DB so _persist_from matches stored rows.
rebuild_client preserves the checkpoint via replace_messages. Clarify that
the active session is summary-only and full history stays on the old id.
2026-07-18 00:25:13 +08:00
NCBM a282231082 docs+ci: document ruff format --check; format app.py 2026-07-17 17:57:00 +08:00
NCBM 844220e013 project: bump version to 0.1.1 2026-07-17 17:41:38 +08:00
NCBM 61c1cb2d70 core/cli: GNU readline UTF-8 meta binds for CJK input
set input-meta/output-meta on and convert-meta off (best-effort; full
grapheme delete still depends on terminal/readline — see CPython #142162).
2026-07-17 15:36:29 +08:00
NCBM f7a562740e core/cli: 10 auto-retries with 5/10/15/20 then +10s waits
default_retry_delays builds (5,10,15,20,30,…80) for DEFAULT_MAX_AUTO_RETRIES=10.
2026-07-17 15:36:29 +08:00
NCBM 39acd5967e tools/chat: rename tools to ask_user_line, ask_user_choice, ask_user_form
Model-facing names via @tool(name=…); Python symbols unchanged.
2026-07-17 15:36:29 +08:00
NCBM b8ed9e0ee9 core/cli: assistant/reasoning on new lines; flush markdown on source change
Labels end with a newline before content. Switching reasoning↔assistant or
hitting tools/errors flushes the assistant markdown buffer so streams do not mix.
2026-07-17 15:36:29 +08:00
NCBM 8ebe68b3af core/cli: remote-first model lists; always fetch GET /models
Prefer provider catalog over config for selection/Tab; fetch at startup,
/provider, /model, and /models. Config-only ids remain as fallback.
2026-07-17 15:36:06 +08:00
NCBM 18008de7f1 docs: document /yolo and --yes soft-confirm overrides 2026-07-17 14:55:34 +08:00
NCBM 2761877460 core/cli: YOLO mode on|off|once for soft destructive confirms
Session /yolo and --yes skip classify_danger prompts; once expires after the
next user turn. Hard path/command denylists unchanged.
2026-07-17 14:55:34 +08:00
61 changed files with 3531 additions and 308 deletions
+21 -12
View File
@@ -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 install # first-time dependency setup
pdm sync # sync after pulling changes pdm sync # sync after pulling changes
pdm run basedpyright . # type checking (basedpyright, "recommended" strictness)
pdm run ruff check . # linting 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) 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 ## Architecture
### Data modeling: `msgspec.Struct` ### 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. Module-level `@tool` handlers. Call `set_workspace_root()` before use.
- **`workspace`**: path resolve under root; path substring denylist; command basename denylist; clearer deny messages. - **`workspace`**: path resolve under primary root **or** temporary allowlist roots; 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`. - **`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` / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep). - **`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=)`; session limit/idle TTL/output budget; close terminate→kill. - 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. - 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`. - 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. - **`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`, `choose_user`, `form_user` (shared `prompting` core). - **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core).
- **`DEFAULT_TOOLS`**: file + process + vcs + chat tool list for a `ToolRegistry`. - **`todo`**: 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`) ### Prompting (`prompting.py`)
@@ -88,8 +97,8 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`. Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data if unset/`:memory:`); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes`, `--stream/--no-stream`, `--quiet`. Root `--log-level`. - **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data if unset/`:memory:`); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--stream/--no-stream`, `--quiet`. Root `--log-level`.
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""``"""`; `/edit` via `$EDITOR`. - Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""``"""`; `/edit` via `$EDITOR`. `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack.
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**. - Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**.
- Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then `/retry`. - Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then `/retry`.
- **`plyngent providers`**, **`config path|edit`**. No providers + `$EDITOR` → optional edit then reload. - **`plyngent providers`**, **`config path|edit`**. No providers + `$EDITOR` → optional edit then reload.
@@ -112,7 +121,7 @@ Basedpyright `recommended`. Ruff includes `ANN` (private return types `ANN202` i
- **Phase G (CLI polish + hardening)** — single-user only; multi-tenant stays Phase H. - **Phase G (CLI polish + hardening)** — single-user only; multi-tenant stays Phase H.
**Done** **Done**
- G0: `prompting` (`ask`/`choose`/`form`/`confirm`) + chat tools (`ask_user`/`choose_user`/`form_user`) - G0: `prompting` (`ask`/`choose`/`form`/`confirm`) + chat tools (`ask_user_line`/`ask_user_choice`/`ask_user_form`)
- G1: `ReasoningDeltaEvent`, `/stream`, `/verbose` - G1: `ReasoningDeltaEvent`, `/stream`, `/verbose`
- G2: `/rename`, `/delete` (confirm), `/export md|json` - G2: `/rename`, `/delete` (confirm), `/export md|json`
- G2.5: Click slash registry (`cli/slash.py`) + `awaitlet` for sync Click / async memory; auto `/help`; completer from `slash.list_commands` - G2.5: Click slash registry (`cli/slash.py`) + `awaitlet` for sync Click / async memory; auto `/help`; completer from `slash.list_commands`
+27 -9
View File
@@ -62,15 +62,25 @@ pdm sync # after pull
pdm run plyngent --help pdm run plyngent --help
``` ```
Dev checks: Dev checks (same order as CI):
```bash ```bash
pdm run basedpyright .
pdm run ruff check . 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 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 ## Basic usage
```bash ```bash
@@ -152,7 +162,7 @@ plyngent chat --session 3
| `--max-rounds` | Tool-loop rounds per turn (default 32) | | `--max-rounds` | Tool-loop rounds per turn (default 32) |
| `--stream` / `--no-stream` | Streaming deltas (default on) | | `--stream` / `--no-stream` | Streaming deltas (default on) |
| `--quiet` | Less status on stderr | | `--quiet` | Less status on stderr |
| `--yes` | Allow destructive tools without confirm (also for one-shot) | | `--yes` | YOLO on: skip destructive-tool confirms for this process |
| `--log-level` | On the root CLI: `DEBUG`, `INFO`, `WARNING`, … | | `--log-level` | On the root CLI: `DEBUG`, `INFO`, `WARNING`, … |
Sessions resume the **most recently updated** session for the current workspace unless you pass `--new` or `--session`. Each session remembers the last **provider** and **model** (restored on resume so you are not re-prompted). Sessions resume the **most recently updated** session for the current workspace unless you pass `--new` or `--session`. Each session remembers the last **provider** and **model** (restored on resume so you are not re-prompted).
@@ -186,16 +196,21 @@ Type `/help` in the REPL for the live list. Common ones:
| Command | Purpose | | Command | Purpose |
|---------|---------| |---------|---------|
| `/status` | Provider, session, context/usage estimates | | `/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 | | `/sessions` | Sessions for this workspace |
| `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) | | `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) |
| `/export [md\|json] [path]` | Transcript from DB (no secrets) | | `/export [md\|json] [path]` | Transcript from DB (no secrets) |
| `/compact` | Soft-compact + model summary into a **new** session | | `/compact` | Soft-compact + model summary into a **new** session |
| `/stream` `/verbose` `/markdown` `/tools` `/rounds` | Toggles and limits | | `/stream` `/verbose` `/markdown` `/tools` `/rounds` | Toggles and limits |
| `/yolo [on\|off\|once]` | Soft destructive confirms: sticky skip, off, or next turn only |
| `/retry` | Re-run incomplete last user turn (after error/cancel) | | `/retry` | Re-run incomplete last user turn (after error/cancel) |
| `/provider` `/model` | Switch without restarting | | `/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 | | `/config` | Edit `plyngent.toml` in `$EDITOR` and reload |
| `/quit` | Leave the REPL | | `/quit` | Leave the REPL |
@@ -209,14 +224,17 @@ User messages are saved immediately. On API error or Ctrl+C, partial assistant/t
## Tools (when enabled) ## 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` / `choose_user` / `form_user`). Default registry: file ops (including `tree` with default noise-dir skips), `run_command` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
Safety defaults: Safety defaults:
- 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). - Command basename denylist (e.g. dangerous shells/utilities).
- Destructive tools (delete/move/overwrite) can require confirm (`confirm_destructive`; default deny in non-TTY). - 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. - 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) ## Usage / context (CLI)
+4 -1
View File
@@ -45,7 +45,10 @@ Then for every projects, check the dev dependency group where you can find tools
Type checking: `pdm run basedpyright .` Type checking: `pdm run basedpyright .`
Linting: `pdm run ruff check .` 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. <!-- Contents in comments are for projects with long-period developments, but this is a new project.
+27
View File
@@ -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
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "plyngent" name = "plyngent"
version = "0.1.0" version = "0.1.2"
description = "LLM chat and agent toolkit" description = "LLM chat and agent toolkit"
authors = [ authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" }, { name = "HivertMoZara", email = "worldmozara@163.com" },
+34 -3
View File
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
from .client import ChatClient from .client import ChatClient
from .events import AgentEvent from .events import AgentEvent
from .todo_stack import TodoStack
from .tools import ToolRegistry from .tools import ToolRegistry
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]] type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
@@ -118,6 +119,7 @@ class ChatAgent:
max_tool_result_chars: int max_tool_result_chars: int
parallel_tools: bool parallel_tools: bool
max_context_tokens: int max_context_tokens: int
todo_stack: TodoStack | None
messages: list[AnyChatMessage] messages: list[AnyChatMessage]
session_usage: TokenUsage session_usage: TokenUsage
last_turn_usage: TokenUsage last_turn_usage: TokenUsage
@@ -143,6 +145,7 @@ class ChatAgent:
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS, max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
parallel_tools: bool = True, parallel_tools: bool = True,
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS, max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
todo_stack: TodoStack | None = None,
) -> None: ) -> None:
self.client = client self.client = client
self.model = model self.model = model
@@ -157,6 +160,7 @@ class ChatAgent:
self.max_tool_result_chars = max_tool_result_chars self.max_tool_result_chars = max_tool_result_chars
self.parallel_tools = parallel_tools self.parallel_tools = parallel_tools
self.max_context_tokens = max_context_tokens self.max_context_tokens = max_context_tokens
self.todo_stack = todo_stack
self.messages = list(messages) if messages is not None else [] self.messages = list(messages) if messages is not None else []
self.session_usage = TokenUsage() self.session_usage = TokenUsage()
self.last_turn_usage = TokenUsage() self.last_turn_usage = TokenUsage()
@@ -200,14 +204,38 @@ class ChatAgent:
if self._persist_from == 0: if self._persist_from == 0:
self._persist_from = 1 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: async def load_history(self) -> None:
"""Replace in-memory messages from the bound memory session.""" """Replace in-memory messages from the bound memory session."""
if self.memory is None or self.session_id is None: if self.memory is None or self.session_id is None:
msg = "load_history requires memory and session_id" msg = "load_history requires memory and session_id"
raise RuntimeError(msg) raise RuntimeError(msg)
self.messages = await self.memory.list_messages(self.session_id) loaded = await self.memory.list_messages(self.session_id)
self._persist_from = len(self.messages) self.replace_messages(loaded, persisted=True)
self._ensure_system_prompt()
async def bind_session(self, session_id: int, *, load: bool = True) -> None: async def bind_session(self, session_id: int, *, load: bool = True) -> None:
"""Attach a memory session id; optionally load existing messages.""" """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. side-effecting tools. Unfinished assistant/stream suffix is rolled back.
""" """
user_index = self._user_index(user_msg) user_index = self._user_index(user_msg)
if self.todo_stack is not None:
self.todo_stack.begin_turn()
completed = False completed = False
turn_usage = TokenUsage() turn_usage = TokenUsage()
@@ -283,6 +313,7 @@ class ChatAgent:
max_tool_result_chars=self.max_tool_result_chars, max_tool_result_chars=self.max_tool_result_chars,
parallel_tools=self.parallel_tools, parallel_tools=self.parallel_tools,
max_context_tokens=self.max_context_tokens, max_context_tokens=self.max_context_tokens,
todo_stack=self.todo_stack,
): ):
if isinstance(event, UsageEvent): if isinstance(event, UsageEvent):
turn_rounds += 1 turn_rounds += 1
+5 -4
View File
@@ -8,8 +8,8 @@ from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage, AssistantChatMessage,
AssistantFunctionToolCall, AssistantFunctionToolCall,
ChatCompletionsParam, ChatCompletionsParam,
DeveloperChatMessage,
SystemChatMessage, SystemChatMessage,
ToolChatMessage,
UserChatMessage, UserChatMessage,
) )
@@ -49,6 +49,8 @@ def format_transcript(messages: Sequence[AnyChatMessage]) -> str:
for msg in messages: for msg in messages:
if isinstance(msg, SystemChatMessage): if isinstance(msg, SystemChatMessage):
lines.append(f"[system] {msg.content}") lines.append(f"[system] {msg.content}")
elif isinstance(msg, DeveloperChatMessage):
lines.append(f"[developer] {msg.content}")
elif isinstance(msg, UserChatMessage): elif isinstance(msg, UserChatMessage):
lines.append(f"[user] {msg.content}") lines.append(f"[user] {msg.content}")
elif isinstance(msg, AssistantChatMessage): 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})") lines.append(f"[assistant tool_call] {call.function.name}({call.function.arguments})")
else: else:
lines.append(f"[assistant tool_call] custom id={call.id}") 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: 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) return "\n".join(lines)
+97 -1
View File
@@ -13,6 +13,7 @@ from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage, AssistantChatMessage,
AssistantFunctionToolCall, AssistantFunctionToolCall,
ChatCompletionsParam, ChatCompletionsParam,
DeveloperChatMessage,
StreamOptions, StreamOptions,
StreamToolCallDelta, StreamToolCallDelta,
ToolChatMessage, ToolChatMessage,
@@ -45,6 +46,7 @@ if TYPE_CHECKING:
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
from .client import ChatClient from .client import ChatClient
from .todo_stack import TodoStack
from .tools import ToolRegistry from .tools import ToolRegistry
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]] type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
@@ -114,6 +116,66 @@ async def _execute_tool_calls(
yield ToolResultEvent(message=tool_msg) 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( async def _non_stream_round(
client: ChatClient, client: ChatClient,
param: ChatCompletionsParam, param: ChatCompletionsParam,
@@ -122,7 +184,14 @@ async def _non_stream_round(
if not response.choices: if not response.choices:
msg = "chat completion response contained no choices" msg = "chat completion response contained no choices"
raise RuntimeError(msg) 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 reasoning = assistant.reasoning_content
if isinstance(reasoning, str) and reasoning: if isinstance(reasoning, str) and reasoning:
yield ReasoningDeltaEvent(content=reasoning) yield ReasoningDeltaEvent(content=reasoning)
@@ -154,6 +223,8 @@ async def _stream_round(
reasoning_parts: list[str] = [] reasoning_parts: list[str] = []
tool_deltas: list[StreamToolCallDelta] = [] tool_deltas: list[StreamToolCallDelta] = []
last_api_usage: object = UNSET last_api_usage: object = UNSET
finish_reason: str | None = None
saw_terminal = False
async for chunk in stream: async for chunk in stream:
parsed = token_usage_from_api(chunk.usage) parsed = token_usage_from_api(chunk.usage)
@@ -162,6 +233,10 @@ async def _stream_round(
if not chunk.choices: if not chunk.choices:
continue continue
choice = chunk.choices[0] 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 delta = choice.delta
if isinstance(delta.reasoning_content, str) and delta.reasoning_content: if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
reasoning_parts.append(delta.reasoning_content) reasoning_parts.append(delta.reasoning_content)
@@ -185,6 +260,16 @@ async def _stream_round(
tool_calls=tool_calls, tool_calls=tool_calls,
reasoning_content=full_reasoning or UNSET, 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 AssistantMessageEvent(message=assistant)
yield UsageEvent( yield UsageEvent(
usage=resolve_round_usage(last_api_usage, param.messages, assistant), 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, max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
parallel_tools: bool = True, parallel_tools: bool = True,
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS, max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
todo_stack: TodoStack | None = None,
) -> AsyncIterator[AgentEvent]: ) -> AsyncIterator[AgentEvent]:
"""Multi-round chat/tool loop; mutates ``messages`` in place and yields events. """Multi-round chat/tool loop; mutates ``messages`` in place and yields events.
@@ -242,6 +328,10 @@ async def run_chat_loop(
text deltas as chunks arrive; tool calls are merged from stream deltas. text deltas as chunks arrive; tool calls are merged from stream deltas.
Multiple tool calls in one round run in parallel when ``parallel_tools``. Multiple tool calls in one round run in parallel when ``parallel_tools``.
Request payloads may shrink older tool results when over ``max_context_tokens``. Request payloads may shrink older tool results when over ``max_context_tokens``.
When *todo_stack* is set and still has open items after a natural stop with
no ``todo_*`` tool use this turn, injects a review user message and continues
once (so the model must check or update the stack).
""" """
tool_items: Sequence[AnyToolItem] | None = None tool_items: Sequence[AnyToolItem] | None = None
if tools is not None and len(tools) > 0: if tools is not None and len(tools) > 0:
@@ -252,6 +342,7 @@ async def run_chat_loop(
# Calibrate soft-compact from last model call's prompt_tokens (API preferred). # Calibrate soft-compact from last model call's prompt_tokens (API preferred).
prompt_tokens_hint: int | None = None prompt_tokens_hint: int | None = None
sent_estimate_tokens: int | None = None sent_estimate_tokens: int | None = None
todo_review_injected = False
while True: while True:
while rounds_used < allowance: while rounds_used < allowance:
@@ -280,6 +371,11 @@ async def run_chat_loop(
assistant = _last_assistant(messages, pre_len) assistant = _last_assistant(messages, pre_len)
tool_calls = assistant.tool_calls tool_calls = assistant.tool_calls
if tool_calls is UNSET or not tool_calls or tools is None: if tool_calls is UNSET or not tool_calls or tools is None:
if todo_stack is not None and todo_stack.needs_review() and not todo_review_injected:
todo_review_injected = True
# Non-user control identity so retry/history don't treat this as human input.
messages.append(DeveloperChatMessage(content=todo_stack.review_prompt()))
continue
return return
async for event in _execute_tool_calls( async for event in _execute_tool_calls(
tools, tools,
+53 -7
View File
@@ -29,10 +29,10 @@ from plyngent.lmproto.openai_compatible.model import (
ChatCompletionsParam, ChatCompletionsParam,
ChunkChoice, ChunkChoice,
DeltaMessage, DeltaMessage,
DeveloperChatMessage,
StreamFunctionDelta, StreamFunctionDelta,
StreamToolCallDelta, StreamToolCallDelta,
SystemChatMessage, SystemChatMessage,
ToolChatMessage,
ToolFunctionItem, ToolFunctionItem,
UserChatMessage, UserChatMessage,
) )
@@ -97,21 +97,22 @@ def chat_messages_to_responses_input(
if isinstance(message, SystemChatMessage): if isinstance(message, SystemChatMessage):
if message.content.strip(): if message.content.strip():
instructions_parts.append(message.content) 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): elif isinstance(message, UserChatMessage):
items.append(ResponseEasyInputMessage(role="user", content=message.content)) items.append(ResponseEasyInputMessage(role="user", content=message.content))
elif isinstance(message, AssistantChatMessage): elif isinstance(message, AssistantChatMessage):
items.extend(_assistant_to_input_items(message)) items.extend(_assistant_to_input_items(message))
elif isinstance(message, ToolChatMessage): else:
# ToolChatMessage (remaining AnyChatMessage arm)
items.append( items.append(
ResponseFunctionToolCallOutput( ResponseFunctionToolCallOutput(
call_id=message.tool_call_id, call_id=message.tool_call_id,
output=message.content, 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 instructions = "\n\n".join(instructions_parts) if instructions_parts else None
return instructions, items return instructions, items
@@ -158,10 +159,33 @@ def _reasoning_summary_text(response: Response) -> str:
return "".join(parts) 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: def response_to_chat_completion(response: Response) -> ChatCompletionResponse:
"""Wrap Responses result as a synthetic chat completion for the agent loop.""" """Wrap Responses result as a synthetic chat completion for the agent loop."""
assistant = response_to_assistant_message(response) 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 usage = response.usage if response.usage is not UNSET else UNSET
created = int(response.created_at) created = int(response.created_at)
return ChatCompletionResponse( 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: def text_delta_chunk(*, model: str, content: str, created: int = 0) -> ChatCompletionChunk:
return ChatCompletionChunk( return ChatCompletionChunk(
id="resp-stream", id="resp-stream",
+12 -1
View File
@@ -8,8 +8,11 @@ from msgspec import UNSET
from plyngent.agent.responses_bridge import ( from plyngent.agent.responses_bridge import (
chat_param_to_responses_kwargs, chat_param_to_responses_kwargs,
finish_reason_chunk,
reasoning_delta_chunk, reasoning_delta_chunk,
response_to_assistant_message,
response_to_chat_completion, response_to_chat_completion,
responses_status_to_finish_reason,
text_delta_chunk, text_delta_chunk,
tool_call_chunks_from_response, tool_call_chunks_from_response,
usage_chunk_from_response, usage_chunk_from_response,
@@ -109,9 +112,17 @@ class ResponsesChatClient:
except TypeError, ValueError, msgspec.ValidationError: except TypeError, ValueError, msgspec.ValidationError:
final = None 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): for chunk in tool_call_chunks_from_response(final, model=model):
yield chunk yield chunk
yield finish_reason_chunk(model=model, finish_reason=finish)
usage = usage_chunk_from_response(final, model=model) usage = usage_chunk_from_response(final, model=model)
if usage is not None: if usage is not None:
yield usage yield usage
+304
View File
@@ -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
+20 -7
View File
@@ -13,9 +13,11 @@ from plyngent.typedef import JSONSchema # noqa: TC001
type ToolHandler = Callable[..., Any | Awaitable[Any]] type ToolHandler = Callable[..., Any | Awaitable[Any]]
type DangerClassifier = Callable[[str, Mapping[str, object]], str | None] 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[ type ToolConfirmHook = Callable[
[str, Mapping[str, object], str], [str, Mapping[str, object], str],
bool | Awaitable[bool], ToolConfirmResult | Awaitable[ToolConfirmResult],
] ]
_PRIMITIVE_SCHEMA: dict[type, JSONSchema] = { _PRIMITIVE_SCHEMA: dict[type, JSONSchema] = {
@@ -191,6 +193,11 @@ class ToolRegistry:
def __len__(self) -> int: def __len__(self) -> int:
return len(self._tools) return len(self._tools)
@property
def soft_confirm(self) -> bool:
"""True when dangerous tools are gated by ``on_confirm``."""
return self._danger is not None and self._on_confirm is not None
async def _invoke(self, definition: ToolDefinition, args: dict[str, object]) -> str: async def _invoke(self, definition: ToolDefinition, args: dict[str, object]) -> str:
try: try:
result = definition.handler(**args) result = definition.handler(**args)
@@ -207,7 +214,11 @@ class ToolRegistry:
return msgspec.json.encode(result).decode() return msgspec.json.encode(result).decode()
async def _maybe_confirm(self, name: str, args: dict[str, object]) -> str | None: 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: if self._danger is None or self._on_confirm is None:
return None return None
reason = self._danger(name, args) reason = self._danger(name, args)
@@ -218,12 +229,14 @@ class ToolRegistry:
reason = self._danger(name, args) reason = self._danger(name, args)
if reason is None: if reason is None:
return None return None
allowed = self._on_confirm(name, args, reason) decision = self._on_confirm(name, args, reason)
if inspect.isawaitable(allowed): if inspect.isawaitable(decision):
allowed = await allowed decision = await decision
if allowed: if decision is True:
return None 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: async def execute(self, name: str, arguments_json: str) -> str:
"""Run a tool by name; returns a string result (errors become error text).""" """Run a tool by name; returns a string result (errors become error text)."""
+36 -6
View File
@@ -171,13 +171,16 @@ async def _run_oneshot(state: ReplState, prompt_text: str) -> int:
try: try:
ok = await run_user_text_with_retries(state.agent, prompt_text, delays=()) ok = await run_user_text_with_retries(state.agent, prompt_text, delays=())
except asyncio.CancelledError: except asyncio.CancelledError:
state.expire_yolo_once(quiet=True)
return EXIT_CANCELLED return EXIT_CANCELLED
except KeyboardInterrupt: except KeyboardInterrupt:
state.expire_yolo_once(quiet=True)
return EXIT_CANCELLED return EXIT_CANCELLED
state.expire_yolo_once(quiet=True)
return EXIT_OK if ok else EXIT_TURN_FAILED return EXIT_OK if ok else EXIT_TURN_FAILED
async def _run_chat( # noqa: C901, PLR0912 — chat orchestration async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
*, *,
config_path: Path | None, config_path: Path | None,
provider_name: str | None, provider_name: str | None,
@@ -206,7 +209,10 @@ async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
_warn_recoverable_providers(store.recoverable_providers) _warn_recoverable_providers(store.recoverable_providers)
_setup_workspace_and_hooks(store, workspace, interactive=interactive) _setup_workspace_and_hooks(store, workspace, interactive=interactive)
confirm_destructive: bool | None = False if yes else None # --yes forces sticky YOLO; else derive from config.confirm_destructive.
from plyngent.cli.state import YoloMode
yolo: YoloMode | None = "on" if yes else None
memory = await MemoryStore.open(_database_config(store, quiet=quiet or oneshot)) memory = await MemoryStore.open(_database_config(store, quiet=quiet or oneshot))
try: try:
@@ -239,8 +245,27 @@ async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
preferred_model=preferred_model, preferred_model=preferred_model,
interactive=interactive, interactive=interactive,
) )
model_id = select_model(provider, preferred=preferred_model, interactive=interactive) # Build client early so we can always try GET /models for remote-first lists.
_ = create_client(provider) from plyngent.cli.models_source import (
client_supports_models,
fetch_remote_model_ids,
model_choices_for_provider,
)
client = create_client(provider)
remote_ids: list[str] | None = None
try:
if client_supports_models(client):
remote_ids = await fetch_remote_model_ids(client)
except RuntimeError, TypeError, OSError, ValueError:
remote_ids = None
choices = model_choices_for_provider(provider, remote_ids=remote_ids)
model_id = select_model(
provider,
preferred=preferred_model,
interactive=interactive,
choices=choices,
)
except ProviderNotSupportedError as exc: except ProviderNotSupportedError as exc:
raise click.ClickException(str(exc)) from exc raise click.ClickException(str(exc)) from exc
@@ -255,8 +280,11 @@ async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
max_rounds=max_rounds, max_rounds=max_rounds,
stream_enabled=stream, stream_enabled=stream,
interactive_limits=interactive, interactive_limits=interactive,
confirm_destructive=confirm_destructive, yolo=yolo,
) )
# Seed remote model cache from startup fetch so Tab/complete stays warm.
if remote_ids is not None:
state.seed_remote_models(remote_ids)
if not quiet and not oneshot: if not quiet and not oneshot:
click.secho(f"workspace: {state.workspace}", fg="bright_black", err=True) click.secho(f"workspace: {state.workspace}", fg="bright_black", err=True)
@@ -279,8 +307,10 @@ async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
finally: finally:
await memory.close() await memory.close()
from plyngent.tools.process.pty_session import PtyManager from plyngent.tools.process.pty_session import PtyManager
from plyngent.tools.temp_workspace import cleanup_temporary_workspaces
PtyManager.close_all() PtyManager.close_all()
_ = cleanup_temporary_workspaces()
def _configure_logging(level: str) -> None: def _configure_logging(level: str) -> None:
@@ -367,7 +397,7 @@ def main(ctx: click.Context, log_level: str) -> None:
"--yes", "--yes",
is_flag=True, is_flag=True,
default=False, default=False,
help="Allow destructive tools without confirm (one-shot / non-interactive).", help="Enable YOLO: skip destructive-tool confirms (sticky for this process).",
) )
@click.option( @click.option(
"--quiet", "--quiet",
+75 -48
View File
@@ -4,7 +4,7 @@ import contextlib
import os import os
import sys import sys
from contextvars import ContextVar from contextvars import ContextVar
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Literal
import click import click
@@ -32,6 +32,8 @@ _TOOL_ARGS_PREVIEW = 80
_verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False) _verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False)
_markdown_enabled: ContextVar[bool] = ContextVar("markdown_enabled", default=True) _markdown_enabled: ContextVar[bool] = ContextVar("markdown_enabled", default=True)
type StreamSource = Literal["reasoning", "assistant"]
def set_verbose_tool_results(enabled: bool) -> None: # noqa: FBT001 def set_verbose_tool_results(enabled: bool) -> None: # noqa: FBT001
"""Set whether tool results print in full (True) or as a short preview.""" """Set whether tool results print in full (True) or as a short preview."""
@@ -87,26 +89,40 @@ def _clear_streamed_lines(line_count: int) -> None:
def _line_count_for_clear(label: str, body: str) -> int: def _line_count_for_clear(label: str, body: str) -> int:
"""Approximate terminal lines used by ``label + body`` for cursor erase.""" """Approximate terminal lines used by ``label\\n + body`` for cursor erase."""
if not body and not label: if not body and not label:
return 0 return 0
text = label + body # Label is on its own line; body may contain newlines.
# +1 for trailing newline after the stream block in render_events. text = f"{label}\n{body}" if label else body
return text.count("\n") + 1 return text.count("\n") + 1
def print_markdown(text: str, *, label: str = "assistant:") -> None: def print_markdown(text: str, *, label: str = "assistant:") -> None:
"""Render *text* as markdown via Rich, with a cyan label.""" """Render *text* as markdown via Rich; *label* on its own line when set."""
from rich.console import Console from rich.console import Console
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.text import Text from rich.text import Text
console = Console(file=sys.stdout, highlight=False) console = Console(file=sys.stdout, highlight=False)
if label: if label:
console.print(Text(label, style="cyan"), end="") console.print(Text(label, style="cyan"))
console.print(Markdown(text)) console.print(Markdown(text))
def _flush_assistant_markdown(body: str, *, pretty: bool) -> None:
"""Replace the plain assistant stream with markdown when enabled."""
if not body.strip():
click.echo()
return
if pretty:
lines = _line_count_for_clear("assistant:", body)
_clear_streamed_lines(lines)
print_markdown(body, label="assistant:")
click.echo()
else:
click.echo()
async def render_events( # noqa: C901, PLR0912, PLR0915 async def render_events( # noqa: C901, PLR0912, PLR0915
events: AsyncIterator[AgentEvent], events: AsyncIterator[AgentEvent],
*, *,
@@ -115,38 +131,65 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
) -> None: ) -> None:
"""Print agent events to the terminal (text deltas stream as they arrive). """Print agent events to the terminal (text deltas stream as they arrive).
When markdown is enabled and stdout is a TTY, the plain streamed assistant Assistant and reasoning each start on a new line after their label. When the
text is replaced at end-of-turn with a Rich markdown render. content source changes (reasoning ↔ assistant, or tools/errors), the
assistant markdown buffer is flushed so streams do not mix and Rich can
re-render completed assistant segments.
""" """
show_full = get_verbose_tool_results() if verbose is None else verbose show_full = get_verbose_tool_results() if verbose is None else verbose
use_markdown = get_markdown_enabled() if markdown is None else markdown use_markdown = get_markdown_enabled() if markdown is None else markdown
pretty = bool(use_markdown and markdown_render_available()) pretty = bool(use_markdown and markdown_render_available())
printed_reasoning = False source: StreamSource | None = None
printed_text = False
assistant_buf: list[str] = [] assistant_buf: list[str] = []
# Tools/errors after assistant text: skip replace (would erase tool lines). printed_reasoning = False
interrupted_by_other = False printed_assistant = False
def flush_assistant() -> None:
nonlocal source, assistant_buf, printed_assistant
if source != "assistant" and not assistant_buf:
return
body = "".join(assistant_buf)
assistant_buf = []
if printed_assistant:
_flush_assistant_markdown(body, pretty=pretty)
printed_assistant = False
if source == "assistant":
source = None
def begin_reasoning() -> None:
nonlocal source, printed_reasoning
if source == "reasoning":
return
if source == "assistant":
flush_assistant()
click.echo()
click.secho("reasoning:", fg="bright_black")
source = "reasoning"
printed_reasoning = True
def begin_assistant() -> None:
nonlocal source, printed_assistant
if source == "assistant":
return
if source == "reasoning":
click.echo() # end reasoning stream line
source = None
click.echo()
click.secho("assistant:", fg="cyan")
source = "assistant"
printed_assistant = True
async for event in events: async for event in events:
if isinstance(event, ReasoningDeltaEvent): if isinstance(event, ReasoningDeltaEvent):
if not printed_reasoning: begin_reasoning()
click.echo()
click.secho("reasoning: ", fg="bright_black", nl=False)
printed_reasoning = True
_echo_stream(event.content) _echo_stream(event.content)
elif isinstance(event, TextDeltaEvent): elif isinstance(event, TextDeltaEvent):
if printed_reasoning and not printed_text: begin_assistant()
click.echo()
if not printed_text:
click.echo()
click.secho("assistant: ", fg="cyan", nl=False)
printed_text = True
assistant_buf.append(event.content) assistant_buf.append(event.content)
_echo_stream(event.content) _echo_stream(event.content)
elif isinstance(event, ToolCallEvent): elif isinstance(event, ToolCallEvent):
if printed_text: flush_assistant()
interrupted_by_other = True
call = event.tool_call call = event.tool_call
if isinstance(call, AssistantFunctionToolCall): if isinstance(call, AssistantFunctionToolCall):
args = _preview(call.function.arguments, _TOOL_ARGS_PREVIEW) args = _preview(call.function.arguments, _TOOL_ARGS_PREVIEW)
@@ -154,8 +197,7 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
else: else:
click.secho(f"\n[tool] custom id={call.id}", fg="yellow") click.secho(f"\n[tool] custom id={call.id}", fg="yellow")
elif isinstance(event, ToolResultEvent): elif isinstance(event, ToolResultEvent):
if printed_text: flush_assistant()
interrupted_by_other = True
content = event.message.content content = event.message.content
if show_full: if show_full:
click.secho(f"[tool ok]\n{content}", fg="magenta") click.secho(f"[tool ok]\n{content}", fg="magenta")
@@ -164,8 +206,7 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
one_line = preview.replace("\n", " ") one_line = preview.replace("\n", " ")
click.secho(f"[tool ok] {one_line}", fg="magenta") click.secho(f"[tool ok] {one_line}", fg="magenta")
elif isinstance(event, ErrorEvent): elif isinstance(event, ErrorEvent):
if printed_text: flush_assistant()
interrupted_by_other = True
suffix = "" suffix = ""
if event.source: if event.source:
suffix += f" source={event.source}" suffix += f" source={event.source}"
@@ -173,15 +214,13 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
suffix += " (fatal)" suffix += " (fatal)"
click.secho(f"\n[error]{suffix} {event.message}", fg="bright_red") click.secho(f"\n[error]{suffix} {event.message}", fg="bright_red")
elif isinstance(event, CancelledEvent): elif isinstance(event, CancelledEvent):
if printed_text: flush_assistant()
interrupted_by_other = True
if event.reason: if event.reason:
click.secho(f"\n[cancelled] {event.reason}", fg="yellow") click.secho(f"\n[cancelled] {event.reason}", fg="yellow")
else: else:
click.secho("\n[cancelled]", fg="yellow") click.secho("\n[cancelled]", fg="yellow")
elif isinstance(event, MaxRoundsEvent): elif isinstance(event, MaxRoundsEvent):
if printed_text: flush_assistant()
interrupted_by_other = True
if event.continued: if event.continued:
click.secho( click.secho(
f"\n[max rounds {event.rounds} reached — continuing with a higher allowance]", f"\n[max rounds {event.rounds} reached — continuing with a higher allowance]",
@@ -195,21 +234,9 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
# AssistantMessageEvent — text already shown via TextDeltaEvent. # AssistantMessageEvent — text already shown via TextDeltaEvent.
_ = event _ = event
full_assistant = "".join(assistant_buf) # End-of-turn: flush any open assistant segment; close reasoning stream.
if pretty and printed_text and full_assistant.strip() and not interrupted_by_other: if assistant_buf or printed_assistant:
# Replace plain stream with markdown (assistant block only). flush_assistant()
lines = _line_count_for_clear("assistant: ", full_assistant) elif printed_reasoning:
# Also account for blank line before label when reasoning was shown.
if printed_reasoning:
lines += 1
_clear_streamed_lines(lines)
if printed_reasoning:
# Reasoning already printed above; leave it and only re-print assistant.
pass
print_markdown(full_assistant, label="assistant: ")
click.echo()
return
if printed_text or printed_reasoning:
click.echo() click.echo()
click.echo() click.echo()
+18 -6
View File
@@ -4,7 +4,6 @@ import asyncio
import contextlib import contextlib
import signal import signal
from contextlib import contextmanager from contextlib import contextmanager
from contextvars import ContextVar
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -13,13 +12,16 @@ if TYPE_CHECKING:
type SigHandler = Callable[[int, FrameType | None], None] | int | None 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] _reinstall_holder: list[Callable[[], None] | None] = [None]
def allow_task_cancel() -> bool: def allow_task_cancel() -> bool:
"""Whether the CLI SIGINT handler should cancel the in-flight turn task.""" """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: 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). Must run on the main thread (signal handlers are main-thread only).
Restores the default SIGINT handler so ``click.confirm`` can receive Restores the default SIGINT handler so ``click.confirm`` can receive
KeyboardInterrupt / Abort instead of the asyncio turn being cancelled. 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 loop_handler_removed = False
previous: SigHandler = signal.SIG_DFL previous: SigHandler = signal.SIG_DFL
try: try:
@@ -56,11 +64,15 @@ def pause_task_cancel_for_prompt() -> Generator[None]:
finally: finally:
with contextlib.suppress(ValueError): with contextlib.suppress(ValueError):
_ = signal.signal(signal.SIGINT, previous) _ = 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] reinstall = _reinstall_holder[0]
if loop_handler_removed and reinstall is not None: if reinstall is not None:
with contextlib.suppress(RuntimeError, NotImplementedError, ValueError): with contextlib.suppress(RuntimeError, NotImplementedError, ValueError):
reinstall() 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: async def run_in_prompt_thread[**P, R](func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
+118 -13
View File
@@ -1,16 +1,20 @@
from __future__ import annotations from __future__ import annotations
import shutil
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING, Literal
from plyngent.cli.interrupt import pause_task_cancel_for_prompt from plyngent.cli.interrupt import pause_task_cancel_for_prompt
from plyngent.prompting import ( from plyngent.prompting import (
ChoiceOption, ChoiceOption,
NonInteractiveError, NonInteractiveError,
ask,
ask_async,
choose, choose,
choose_async, choose_async,
configure_prompting, configure_prompting,
confirm, confirm,
confirm_async, confirm_async,
get_prompt_backend,
) )
from plyngent.tools.process.pty_session import PtyManager from plyngent.tools.process.pty_session import PtyManager
@@ -19,6 +23,78 @@ if TYPE_CHECKING:
type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"] 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: def _prompt_continue_limit_sync(reason: str) -> bool:
try: try:
@@ -44,33 +120,62 @@ async def prompt_continue_limit_async(reason: str) -> bool:
return False 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 del args
try: try:
return confirm( _echo_tool_confirm(name, reason)
f"[confirm] tool {name!r}: {reason}\nAllow this tool call?", allowed = confirm("Allow this tool call?", default=False)
default=False,
)
except NonInteractiveError: except NonInteractiveError:
return False 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: def prompt_confirm_tool(name: str, args: Mapping[str, object], reason: str) -> bool | str:
"""Ask whether to allow a destructive tool call (TTY). Default is deny.""" """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(): with pause_task_cancel_for_prompt():
return _prompt_confirm_tool_sync(name, args, reason) return _prompt_confirm_tool_sync(name, args, reason)
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool: async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool | str:
"""Async variant: confirm off the event loop.""" """Async confirm: True allow, False deny, str = deny with user comment."""
del args del args
try: try:
return await confirm_async( # Box is printed inside the worker thread via confirm's backend.
f"[confirm] tool {name!r}: {reason}\nAllow this tool call?", def _run() -> bool:
default=False, _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: except NonInteractiveError:
return False 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( def _prompt_workspace_mismatch_sync(
+29 -8
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import inspect import inspect
from typing import TYPE_CHECKING, Protocol, cast, runtime_checkable from typing import TYPE_CHECKING, Literal, Protocol, cast, runtime_checkable
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Sequence
@@ -11,6 +11,8 @@ if TYPE_CHECKING:
# Cache remote catalog this long (seconds) unless /models --refresh. # Cache remote catalog this long (seconds) unless /models --refresh.
DEFAULT_MODELS_CACHE_TTL = 300.0 DEFAULT_MODELS_CACHE_TTL = 300.0
type ModelListPrefer = Literal["remote", "union", "config"]
@runtime_checkable @runtime_checkable
class SupportsModels(Protocol): class SupportsModels(Protocol):
@@ -25,12 +27,30 @@ def config_model_ids(provider: Provider) -> list[str]:
def merge_model_choices( def merge_model_choices(
config_ids: Iterable[str], config_ids: Iterable[str],
remote_ids: Iterable[str] | None = None, remote_ids: Iterable[str] | None = None,
*,
prefer: ModelListPrefer = "remote",
) -> list[str]: ) -> list[str]:
"""Union config and remote ids (sorted, unique).""" """Merge config and remote model ids.
merged: set[str] = {i for i in config_ids if i}
if remote_ids is not None: *prefer*:
merged.update(i for i in remote_ids if i) - ``remote`` (default): remote catalog first (sorted), then config-only ids
return sorted(merged) - ``union``: sorted unique union
- ``config``: config first, then remote-only ids
"""
config_list = [i for i in config_ids if i]
remote_list = [i for i in (remote_ids or ()) if i]
if not remote_list:
return sorted(set(config_list))
if prefer == "union":
return sorted(set(config_list) | set(remote_list))
remote_sorted = sorted(set(remote_list))
config_only = sorted(set(config_list) - set(remote_sorted))
if prefer == "remote":
return [*remote_sorted, *config_only]
# config first
config_sorted = sorted(set(config_list))
remote_only = sorted(set(remote_list) - set(config_sorted))
return [*config_sorted, *remote_only]
def client_supports_models(client: object) -> bool: def client_supports_models(client: object) -> bool:
@@ -57,6 +77,7 @@ def model_choices_for_provider(
provider: Provider, provider: Provider,
*, *,
remote_ids: Sequence[str] | None = None, remote_ids: Sequence[str] | None = None,
prefer: ModelListPrefer = "remote",
) -> list[str]: ) -> list[str]:
"""Config plus remote catalog for selection / Tab complete.""" """Config plus remote catalog for selection / Tab complete (remote-first)."""
return merge_model_choices(config_model_ids(provider), remote_ids) return merge_model_choices(config_model_ids(provider), remote_ids, prefer=prefer)
+26 -2
View File
@@ -54,8 +54,10 @@ def build_completer(state: ReplState) -> Callable[[str, int], str | None]:
options = filter_prefix(text, slash_commands()) options = filter_prefix(text, slash_commands())
else: else:
head = buffer[:begidx].strip() head = buffer[:begidx].strip()
command = head.split()[0] if head else "" tokens = head.split() if head else []
options = complete_slash_args(state, command, text) 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): if state_index < len(options):
return options[state_index] return options[state_index]
return None return None
@@ -77,6 +79,27 @@ def bind_tab_complete(readline_mod: object) -> None:
_ = parse("python:bind ^I rl_complete") _ = parse("python:bind ^I rl_complete")
def bind_utf8_input(readline_mod: object) -> None:
"""Best-effort 8-bit / UTF-8 settings for GNU readline (CJK backspace).
GNU readline can mishandle wide characters when meta conversion is on.
These binds are no-ops or ignored on libedit. Full grapheme editing still
depends on the terminal and readline version (see CPython #142162).
"""
parse = getattr(readline_mod, "parse_and_bind", None)
if not callable(parse):
return
for cmd in (
"set input-meta on",
"set output-meta on",
"set convert-meta off",
"set enable-meta-key on",
"set horizontal-scroll-mode off",
):
with contextlib.suppress(Exception):
_ = parse(cmd)
def setup_readline(state: ReplState) -> None: def setup_readline(state: ReplState) -> None:
"""Configure Tab completion and persistent history when readline is available.""" """Configure Tab completion and persistent history when readline is available."""
try: try:
@@ -85,6 +108,7 @@ def setup_readline(state: ReplState) -> None:
return return
bind_tab_complete(readline) bind_tab_complete(readline)
bind_utf8_input(readline)
# Treat path-like chars as part of a token so /help completes as one word. # Treat path-like chars as part of a token so /help completes as one word.
readline.set_completer_delims(" \t\n") readline.set_completer_delims(" \t\n")
readline.set_completer(build_completer(state)) readline.set_completer(build_completer(state))
+9 -1
View File
@@ -25,12 +25,14 @@ def _echo_user(text: str) -> None:
async def run_repl(state: ReplState) -> None: async def run_repl(state: ReplState) -> None:
"""Interactive chat loop with readline editing, history, and Tab completion.""" """Interactive chat loop with readline editing, history, and Tab completion."""
setup_readline(state) setup_readline(state)
yolo = state.effective_yolo()
yolo_part = f" yolo={yolo}" if yolo != "off" else ""
click.echo( click.echo(
f"plyngent chat provider={state.provider_name} model={state.model} " f"plyngent chat provider={state.provider_name} model={state.model} "
f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} " f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} "
f"rounds={state.max_rounds} messages={len(state.agent.messages)} " f"rounds={state.max_rounds} messages={len(state.agent.messages)} "
f"stream={'on' if state.agent.stream else 'off'} " f"stream={'on' if state.agent.stream else 'off'} "
f"verbose={'on' if state.verbose else 'off'}" f"verbose={'on' if state.verbose else 'off'}{yolo_part}"
) )
click.echo('Type /help for commands. Multiline: """""". Empty line is ignored.') click.echo('Type /help for commands. Multiline: """""". Empty line is ignored.')
@@ -52,8 +54,14 @@ async def run_repl(state: ReplState) -> None:
text = state.pending_user_text text = state.pending_user_text
state.pending_user_text = None state.pending_user_text = None
_echo_user(text) _echo_user(text)
try:
_ = await run_user_text_with_retries(state.agent, text) _ = await run_user_text_with_retries(state.agent, text)
finally:
state.expire_yolo_once()
continue continue
_echo_user(entry) _echo_user(entry)
try:
_ = await run_user_text_with_retries(state.agent, entry) _ = await run_user_text_with_retries(state.agent, entry)
finally:
state.expire_yolo_once()
+20 -2
View File
@@ -17,8 +17,23 @@ if TYPE_CHECKING:
from plyngent.agent import AgentEvent from plyngent.agent import AgentEvent
from plyngent.agent.chat import ChatAgent from plyngent.agent.chat import ChatAgent
# Wait before retry attempt 1, 2, and 3 (after the first failure). # Auto-retry budget after the first failure (10 attempts by default).
DEFAULT_RETRY_DELAYS_SECONDS: tuple[float, ...] = (10.0, 20.0, 30.0) DEFAULT_MAX_AUTO_RETRIES = 10
# First four waits; each further wait is previous + 10s.
_RETRY_BASE_DELAYS_SECONDS: tuple[float, ...] = (5.0, 10.0, 15.0, 20.0)
def default_retry_delays(max_retries: int = DEFAULT_MAX_AUTO_RETRIES) -> tuple[float, ...]:
"""Build wait times: 5, 10, 15, 20, then +10s each step, length *max_retries*."""
if max_retries <= 0:
return ()
delays: list[float] = list(_RETRY_BASE_DELAYS_SECONDS)
while len(delays) < max_retries:
delays.append(delays[-1] + 10.0)
return tuple(delays[:max_retries])
DEFAULT_RETRY_DELAYS_SECONDS: tuple[float, ...] = default_retry_delays()
_PREVIEW_LEN = 80 _PREVIEW_LEN = 80
@@ -51,6 +66,9 @@ async def run_cancellable[T](coro: Coroutine[object, object, T]) -> T:
installed = False installed = False
def _on_sigint() -> None: 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(): if allow_task_cancel() and not task.done():
_ = task.cancel() _ = task.cancel()
+457 -46
View File
@@ -15,7 +15,8 @@ from plyngent.cli.selection import select_model, select_provider
from plyngent.lmproto.openai_compatible.model import ( from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage, AssistantChatMessage,
AssistantFunctionToolCall, AssistantFunctionToolCall,
ToolChatMessage, DeveloperChatMessage,
SystemChatMessage,
UserChatMessage, UserChatMessage,
) )
from plyngent.runtime import ProviderNotSupportedError from plyngent.runtime import ProviderNotSupportedError
@@ -23,23 +24,67 @@ from plyngent.runtime import ProviderNotSupportedError
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Awaitable, Sequence from collections.abc import Awaitable, Sequence
from plyngent.cli.state import ReplState from plyngent.cli.state import ReplState, YoloMode
from plyngent.lmproto.openai_compatible.model import AnyChatMessage from plyngent.lmproto.openai_compatible.model import AnyChatMessage
_DEFAULT_HISTORY_LINES = 20 _DEFAULT_HISTORY_LINES = 20
_CONTENT_PREVIEW = 200 _CONTENT_PREVIEW = 200
_COMPACT_PREVIEW = 400 _COMPACT_PREVIEW = 400
_ON_OFF_CHOICES = ("on", "off") _ON_OFF_CHOICES = ("on", "off")
_YOLO_MODE_CHOICES = ("on", "off", "once")
_EXPORT_FORMAT_CHOICES = ("md", "json") _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 = ( HELP_FOOTER = (
"User messages are saved immediately. On API errors or Ctrl+C, partial\n" "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" "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" "works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n"
"\n" "\n"
"Tab completes slash commands and some arguments (provider, model, tools,\n" "Tab completes slash commands and arguments (provider, model, session ids,\n"
"stream, verbose, export). Use --session ID or /resume to continue a prior\n" "todos actions, on/off, yolo, export, flags). Use --session ID or /resume to\n"
"chat after restart.\n" "continue a prior chat after restart.\n"
"\n" "\n"
'Multiline: start a message with """ then end a later line with """.\n' 'Multiline: start a message with """ then end a later line with """.\n'
"Long prompts: /edit opens $EDITOR.\n" "Long prompts: /edit opens $EDITOR.\n"
@@ -86,6 +131,30 @@ class OnOffParam(click.ParamType[bool]):
ON_OFF = OnOffParam() ON_OFF = OnOffParam()
class YoloModeParam(click.ParamType[str]):
"""Accept on|off|once for soft destructive-tool confirms."""
name: str = "yolo_mode"
@override
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> str:
if isinstance(value, str) and value in _YOLO_MODE_CHOICES:
return value
token = str(value).strip().lower()
if token in _YOLO_MODE_CHOICES:
return token
msg = "expected on, off, or once"
raise click.BadParameter(msg, ctx=ctx, param=param)
@override
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
del ctx, param
return _filter_choices(incomplete, _YOLO_MODE_CHOICES)
YOLO_MODE = YoloModeParam()
class ExportFormatParam(click.ParamType[str]): class ExportFormatParam(click.ParamType[str]):
"""First token of /export: md|json (or a path if not a format).""" """First token of /export: md|json (or a path if not a format)."""
@@ -105,6 +174,80 @@ class ExportFormatParam(click.ParamType[str]):
EXPORT_FORMAT = ExportFormatParam() 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]): class ProviderNameParam(click.ParamType[str]):
name: str = "provider" name: str = "provider"
@@ -207,21 +350,55 @@ def slash_command_names() -> list[str]:
return sorted(f"/{name}" for name in slash.list_commands(ctx)) return sorted(f"/{name}" for name in slash.list_commands(ctx))
def complete_slash_args(state: ReplState, command: str, incomplete: str) -> list[str]: def complete_slash_args(
"""Tab-complete arguments for ``command`` (e.g. ``/stream``) from ParamTypes. 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 *prior_args* are tokens already typed after the command name (so
implements :meth:`~click.ParamType.shell_complete` with candidates. ``/todos done t`` can complete todo ids). Options are completed when
*incomplete* starts with ``-``.
""" """
name = command.lstrip("/").lower() name = command.lstrip("/").lower()
ctx = click.Context(slash, obj=state) ctx = click.Context(slash, obj=state)
cmd = slash.get_command(ctx, name) cmd = slash.get_command(ctx, name)
if cmd is None: if cmd is None:
return [] return []
prior = list(prior_args or ())
with click.Context(cmd, info_name=name, parent=ctx, obj=state) as sub: with click.Context(cmd, info_name=name, parent=ctx, obj=state) as sub:
for param in cmd.params: # Flag / option completion (e.g. --persist, --full).
if not isinstance(param, click.Argument): if incomplete.startswith("-"):
continue 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) items = param.type.shell_complete(sub, param, incomplete)
if items: if items:
return [item.value for item in items] return [item.value for item in items]
@@ -355,7 +532,8 @@ def status_cmd(state: ReplState) -> None:
f"tools={'on' if state.tools_enabled else 'off'} " f"tools={'on' if state.tools_enabled else 'off'} "
f"rounds={state.max_rounds} " f"rounds={state.max_rounds} "
f"stream={'on' if state.agent.stream else 'off'} " f"stream={'on' if state.agent.stream else 'off'} "
f"verbose={'on' if state.verbose else 'off'}\n" f"verbose={'on' if state.verbose else 'off'} "
f"yolo={state.effective_yolo()}\n"
f"context_tokens={ctx_tilde}{ctx_tokens}/{ctx_budget} ({ctx_tag}) " f"context_tokens={ctx_tilde}{ctx_tokens}/{ctx_budget} ({ctx_tag}) "
f"context_chars={ctx_chars} " f"context_chars={ctx_chars} "
f"tool_result_max={state.agent.max_tool_result_chars}\n" f"tool_result_max={state.agent.max_tool_result_chars}\n"
@@ -372,6 +550,7 @@ def status_cmd(state: ReplState) -> None:
def sessions_cmd(state: ReplState) -> None: def sessions_cmd(state: ReplState) -> None:
"""List sessions for this workspace (newest first).""" """List sessions for this workspace (newest first)."""
sessions = _await(state.memory.list_sessions(workspace=state.workspace)) sessions = _await(state.memory.list_sessions(workspace=state.workspace))
state.remember_session_ids([s.sid for s in sessions])
if not sessions: if not sessions:
click.echo(f"(no sessions for workspace {state.workspace})") click.echo(f"(no sessions for workspace {state.workspace})")
return return
@@ -409,7 +588,7 @@ def rename_cmd(state: ReplState, name: tuple[str, ...]) -> None:
@slash.command("delete") @slash.command("delete")
@click.argument("session_id", type=int, required=False) @click.argument("session_id", type=SESSION_ID, required=False)
@click.pass_obj @click.pass_obj
def delete_cmd(state: ReplState, session_id: int | None) -> None: def delete_cmd(state: ReplState, session_id: int | None) -> None:
"""Hard-delete a session (confirm; current → new empty).""" """Hard-delete a session (confirm; current → new empty)."""
@@ -512,7 +691,7 @@ def export_cmd(state: ReplState, parts: tuple[str, ...]) -> None:
@slash.command("resume") @slash.command("resume")
@click.argument("session_id", type=int, required=False) @click.argument("session_id", type=SESSION_ID, required=False)
@click.pass_obj @click.pass_obj
def resume_cmd(state: ReplState, session_id: int | None) -> None: def resume_cmd(state: ReplState, session_id: int | None) -> None:
"""Resume session id, or latest for this workspace if omitted.""" """Resume session id, or latest for this workspace if omitted."""
@@ -550,6 +729,11 @@ def compact_cmd(state: ReplState, name: str | None) -> None:
return return
preview = summary if len(summary) <= _COMPACT_PREVIEW else summary[:_COMPACT_PREVIEW] + "" preview = summary if len(summary) <= _COMPACT_PREVIEW else summary[:_COMPACT_PREVIEW] + ""
click.echo(f"compacted session {old_id} -> new session {new_id}") 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") click.secho(preview, fg="bright_black")
@@ -581,7 +765,8 @@ def provider_cmd(state: ReplState, name: str | None) -> None:
state.provider_name = pname state.provider_name = pname
state.provider = provider state.provider = provider
state.rebuild_client() state.rebuild_client()
choices = _await(state.merged_model_choices(refresh=False)) # Always request remote catalog for the new provider (bypass stale cache).
choices = _await(state.merged_model_choices(refresh=True))
if prev_model and (prev_model in choices or prev_model in provider.models): if prev_model and (prev_model in choices or prev_model in provider.models):
state.model = prev_model state.model = prev_model
else: else:
@@ -610,13 +795,19 @@ def provider_cmd(state: ReplState, name: str | None) -> None:
@slash.command("models") @slash.command("models")
@click.option("--refresh", is_flag=True, help="Bypass cache and re-fetch GET /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 @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 (config plus remote GET /models).""" """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 remote: list[str] | None = None
remote_err: str | None = None remote_err: str | None = None
try: try:
remote = _await(state.ensure_remote_models(refresh=refresh)) remote = _await(state.ensure_remote_models(refresh=True))
except (RuntimeError, TypeError, OSError, ValueError) as exc: except (RuntimeError, TypeError, OSError, ValueError) as exc:
remote_err = str(exc) remote_err = str(exc)
remote = state.cached_remote_models() remote = state.cached_remote_models()
@@ -634,6 +825,14 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
except (KeyError, ValueError) as exc: except (KeyError, ValueError) as exc:
click.secho(f"could not recover provider: {exc}", fg="yellow", err=True) 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()) config_ids = set(state.config_model_ids())
choices = model_choices_for_provider(state.provider, remote_ids=remote) choices = model_choices_for_provider(state.provider, remote_ids=remote)
@@ -643,10 +842,10 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
remote_set = set(remote or ()) remote_set = set(remote or ())
for mid in choices: for mid in choices:
tags: list[str] = [] tags: list[str] = []
if mid in config_ids:
tags.append("config")
if mid in remote_set: if mid in remote_set:
tags.append("remote") tags.append("remote")
if mid in config_ids:
tags.append("config")
suffix = f" ({', '.join(tags)})" if tags else "" suffix = f" ({', '.join(tags)})" if tags else ""
mark = " *" if mid == state.model else "" mark = " *" if mid == state.model else ""
click.echo(f"{mid}{mark}{suffix}") click.echo(f"{mid}{mark}{suffix}")
@@ -655,21 +854,30 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
click.secho(f"remote list unavailable: {remote_err}", fg="yellow", err=True) click.secho(f"remote list unavailable: {remote_err}", fg="yellow", err=True)
elif remote is not None: elif remote is not None:
click.echo( click.echo(
f"({len(remote)} remote, {len(config_ids)} config; cache TTL {int(DEFAULT_MODELS_CACHE_TTL)}s)", f"(remote-first: {len(remote)} remote, {len(config_ids)} config; "
f"cache TTL {int(DEFAULT_MODELS_CACHE_TTL)}s)",
err=True, err=True,
) )
@slash.command("model") @slash.command("model")
@click.argument("model_id", required=False, type=MODEL_ID) @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 @click.pass_obj
def model_cmd(state: ReplState, model_id: str | None) -> None: def model_cmd(state: ReplState, model_id: str | None, *, persist: bool) -> None:
"""Show or switch model (Tab: config plus cached remote).""" """Show or switch model (Tab: remote-first plus config; live fetch on pick).
if not model_id:
click.echo(f"model={state.model}") ``/model --persist`` saves the active model id into the provider catalog in
return TOML (faster Tab/list on next launch). ``/model <id> --persist`` switches
then saves.
"""
if model_id:
try: try:
choices = _await(state.merged_model_choices(refresh=False)) choices = _await(state.merged_model_choices(refresh=True))
state.model = select_model( state.model = select_model(
state.provider, state.provider,
preferred=model_id.strip(), preferred=model_id.strip(),
@@ -680,6 +888,17 @@ def model_cmd(state: ReplState, model_id: str | None) -> None:
click.echo(f"switched model to {state.model}") click.echo(f"switched model to {state.model}")
except click.ClickException as exc: except click.ClickException as exc:
click.echo(f"error: {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") @slash.command("tools")
@@ -698,6 +917,24 @@ def tools_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT001
click.echo(f"tools={'on' if enabled else 'off'}") click.echo(f"tools={'on' if enabled else 'off'}")
@slash.command("yolo")
@click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]")
@click.pass_obj
def yolo_cmd(state: ReplState, mode: str | None) -> None:
"""Show or set YOLO mode for soft destructive-tool confirms.
``off`` (default when config ``confirm_destructive`` is true): prompt on
delete/move/overwrite (deny in non-TTY). ``on``: skip confirms for the
process. ``once``: skip for the next user turn only, then return to ``off``.
Path/command denylists still apply. Omit the argument to print the value.
"""
if mode is None:
click.echo(f"yolo={state.effective_yolo()}")
return
state.set_yolo(cast("YoloMode", mode))
click.echo(f"yolo={state.effective_yolo()}")
@slash.command("stream") @slash.command("stream")
@click.argument("enabled", required=False, type=ON_OFF, metavar="[on|off]") @click.argument("enabled", required=False, type=ON_OFF, metavar="[on|off]")
@click.pass_obj @click.pass_obj
@@ -753,45 +990,160 @@ def markdown_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT00
@slash.command("rounds") @slash.command("rounds")
@click.argument("n", required=False, type=int) @click.argument("n", required=False, type=ROUNDS)
@click.pass_obj @click.pass_obj
def rounds_cmd(state: ReplState, n: int | None) -> None: def rounds_cmd(state: ReplState, n: int | None) -> None:
"""Show or set max tool-loop rounds.""" """Show or set max tool-loop rounds."""
if n is None: if n is None:
click.echo(f"max_rounds={state.max_rounds}") click.echo(f"max_rounds={state.max_rounds}")
return return
if n < 1:
msg = "max_rounds must be >= 1"
raise click.UsageError(msg)
state.max_rounds = n state.max_rounds = n
state.agent.max_rounds = n state.agent.max_rounds = n
click.echo(f"max_rounds={state.max_rounds}") click.echo(f"max_rounds={state.max_rounds}")
@slash.command("history") @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 @click.pass_obj
def history_cmd(state: ReplState, n: int | None) -> None: def history_cmd(
"""Show last n messages in this session (default 20).""" state: ReplState,
limit = _DEFAULT_HISTORY_LINES if n is None else n n: int | None,
if limit < 1: *,
msg = "n must be >= 1" 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) raise click.UsageError(msg)
limit = _DEFAULT_HISTORY_LINES if n is None else n
messages = state.agent.messages messages = state.agent.messages
if not messages: if not messages:
click.echo("(no messages in this session)") click.echo("(no messages in this session)")
return return
start = max(0, len(messages) - limit) start = max(0, len(messages) - limit)
click.echo(f"session={state.session_id} messages={len(messages)} showing={len(messages) - start}") slice_msgs = messages[start:]
for offset, message in enumerate(messages[start:]): # Full: --full, or single-message window (last / 1) unless --preview.
click.echo(_format_history_message(start + offset, message)) 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: 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( click.secho(
f"(pending retry) user: {_preview_content(state.agent.pending_retry_text)}", f"(pending retry) user: {_preview_content(pending)}",
fg="yellow", 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") @slash.command("retry")
@click.pass_obj @click.pass_obj
def retry_cmd(state: ReplState) -> None: def retry_cmd(state: ReplState) -> None:
@@ -811,9 +1163,71 @@ def _preview_content(text: str | None) -> str:
return text[:_CONTENT_PREVIEW] + "" 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: def _format_history_message(index: int, message: AnyChatMessage) -> str:
if isinstance(message, UserChatMessage): if isinstance(message, UserChatMessage):
return f"{index}. user: {_preview_content(message.content)}" 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): if isinstance(message, AssistantChatMessage):
parts: list[str] = [] parts: list[str] = []
if isinstance(message.content, str) and message.content: if isinstance(message.content, str) and message.content:
@@ -829,11 +1243,8 @@ def _format_history_message(index: int, message: AnyChatMessage) -> str:
parts.append(f"tool_calls=[{', '.join(names)}]") parts.append(f"tool_calls=[{', '.join(names)}]")
body = " ".join(parts) if parts else "(empty)" body = " ".join(parts) if parts else "(empty)"
return f"{index}. assistant: {body}" return f"{index}. assistant: {body}"
if isinstance(message, ToolChatMessage): # ToolChatMessage
return f"{index}. tool({message.tool_call_id}): {_preview_content(message.content)}" 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: def _run_slash_argv(args: Sequence[str], state: ReplState) -> None:
+151 -11
View File
@@ -4,10 +4,11 @@ import contextlib
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, Literal, cast
from plyngent.agent import ChatAgent, ChatClient, ToolRegistry from plyngent.agent import ChatAgent, ChatClient, ToolRegistry
from plyngent.agent.loop import DEFAULT_MAX_ROUNDS from plyngent.agent.loop import DEFAULT_MAX_ROUNDS
from plyngent.agent.todo_stack import TodoStack
from plyngent.cli.models_source import ( from plyngent.cli.models_source import (
DEFAULT_MODELS_CACHE_TTL, DEFAULT_MODELS_CACHE_TTL,
client_supports_models, client_supports_models,
@@ -17,14 +18,18 @@ from plyngent.cli.models_source import (
) )
from plyngent.memory.database.store import normalize_workspace from plyngent.memory.database.store import normalize_workspace
from plyngent.runtime import create_client from plyngent.runtime import create_client
from plyngent.tools import DEFAULT_TOOLS, set_workspace_root from plyngent.tools import DEFAULT_TOOLS, set_todo_stack, set_workspace_root
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Sequence
from plyngent.config.models import Provider from plyngent.config.models import Provider
from plyngent.config.store import ConfigStore from plyngent.config.store import ConfigStore
from plyngent.memory import MemoryStore from plyngent.memory import MemoryStore
from plyngent.memory.database.schema import Session as SessionRow from plyngent.memory.database.schema import Session as SessionRow
type YoloMode = Literal["off", "on", "once"]
@dataclass @dataclass
class ReplState: class ReplState:
@@ -44,13 +49,18 @@ class ReplState:
markdown_enabled: bool = True markdown_enabled: bool = True
# One-shot / scripts: never prompt to raise tool-loop limits. # One-shot / scripts: never prompt to raise tool-loop limits.
interactive_limits: bool = True interactive_limits: bool = True
# When False, skip destructive-tool confirms (e.g. --yes). # Soft destructive-tool confirms: None → derive from config.confirm_destructive.
confirm_destructive: bool | None = None # off = confirm; on = skip (sticky); once = skip next user turn then off.
yolo: YoloMode | None = None
# Set by /edit; REPL sends as the next user turn then clears. # Set by /edit; REPL sends as the next user turn then clears.
pending_user_text: str | None = None pending_user_text: str | None = None
client: ChatClient = field(init=False) client: ChatClient = field(init=False)
agent: ChatAgent = field(init=False) agent: ChatAgent = field(init=False)
session_id: int | None = None session_id: int | None = None
todo_stack: TodoStack = field(default_factory=TodoStack)
_todo_persist_tasks: set[object] = field(default_factory=set, init=False, repr=False)
# 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 GET /models cache (per provider base).
_remote_models: list[str] | None = field(default=None, init=False, repr=False) _remote_models: list[str] | None = field(default=None, init=False, repr=False)
_remote_models_fetched_at: float | None = field(default=None, init=False, repr=False) _remote_models_fetched_at: float | None = field(default=None, init=False, repr=False)
@@ -63,6 +73,7 @@ class ReplState:
self.workspace = Path(self.workspace).expanduser().resolve() self.workspace = Path(self.workspace).expanduser().resolve()
self.agent = self._make_agent() self.agent = self._make_agent()
self.sync_display_flags() self.sync_display_flags()
self._bind_todo_tools()
def sync_display_flags(self) -> None: def sync_display_flags(self) -> None:
from plyngent.cli.display import set_markdown_enabled, set_verbose_tool_results from plyngent.cli.display import set_markdown_enabled, set_verbose_tool_results
@@ -77,10 +88,70 @@ class ReplState:
raise RuntimeError(msg) raise RuntimeError(msg)
return key return key
def _confirm_destructive(self) -> bool: def effective_yolo(self) -> YoloMode:
if self.confirm_destructive is not None: """Resolved YOLO mode (session override or config default)."""
return self.confirm_destructive if self.yolo is not None:
return self.config.agent_config.confirm_destructive return self.yolo
return "off" if self.config.agent_config.confirm_destructive else "on"
def soft_confirm_enabled(self) -> bool:
"""Whether destructive tools should prompt (or deny non-interactively)."""
return self.effective_yolo() == "off"
def set_yolo(self, mode: YoloMode) -> None:
"""Set YOLO mode; rebuild tool registry when soft-confirm hooks change."""
prev = self.soft_confirm_enabled()
self.yolo = mode
if prev != self.soft_confirm_enabled():
self.rebuild_client()
def expire_yolo_once(self, *, quiet: bool = False) -> None:
"""If mode is ``once``, drop back to ``off`` after a user turn."""
if self.effective_yolo() != "once":
return
self.set_yolo("off")
if not quiet:
import click
click.secho("yolo=off (once expired)", fg="bright_black", err=True)
def _bind_todo_tools(self) -> None:
"""Point module-level todo tools at this session stack + persist hook."""
import asyncio
def on_change() -> None:
if self.session_id is None:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
task = loop.create_task(self.memory.update_session_todo_stack(self.session_id, self.todo_stack.to_raw()))
# Keep a strong ref until done so the task is not GC'd mid-flight.
self._todo_persist_tasks.add(task)
task.add_done_callback(self._todo_persist_tasks.discard)
set_todo_stack(self.todo_stack, on_change=on_change)
async def persist_todo_stack(self) -> None:
"""Write the in-memory todo stack to the active session row."""
if self.session_id is None:
return
_ = await self.memory.update_session_todo_stack(self.session_id, self.todo_stack.to_raw())
async def load_todo_stack(self) -> None:
"""Load todo stack from the active session (empty if none)."""
if self.session_id is None:
self.todo_stack = TodoStack()
self._bind_todo_tools()
if hasattr(self, "agent"):
self.agent.todo_stack = self.todo_stack
return
raw = await self.memory.get_session_todo_stack(self.session_id)
self.todo_stack = TodoStack.from_raw(raw)
self._bind_todo_tools()
if hasattr(self, "agent"):
self.agent.todo_stack = self.todo_stack
def _tool_registry(self) -> ToolRegistry | None: def _tool_registry(self) -> ToolRegistry | None:
if not self.tools_enabled: if not self.tools_enabled:
@@ -88,7 +159,7 @@ class ReplState:
from plyngent.cli.limits import prompt_confirm_tool_async from plyngent.cli.limits import prompt_confirm_tool_async
from plyngent.tools.danger import classify_danger from plyngent.tools.danger import classify_danger
if self._confirm_destructive(): if self.soft_confirm_enabled():
return ToolRegistry( return ToolRegistry(
list(DEFAULT_TOOLS), list(DEFAULT_TOOLS),
danger=classify_danger, danger=classify_danger,
@@ -115,18 +186,22 @@ class ReplState:
max_tool_result_chars=agent_cfg.max_tool_result_chars, max_tool_result_chars=agent_cfg.max_tool_result_chars,
parallel_tools=agent_cfg.parallel_tools, parallel_tools=agent_cfg.parallel_tools,
max_context_tokens=agent_cfg.max_context_tokens, max_context_tokens=agent_cfg.max_context_tokens,
todo_stack=self.todo_stack,
) )
def rebuild_client(self) -> None: def rebuild_client(self) -> None:
"""Recreate client and agent after provider/model/tools change.""" """Recreate client and agent after provider/model/tools change."""
messages = list(self.agent.messages) messages = list(self.agent.messages)
persist_from = self.agent.persist_from
# Preserve live stream toggle if agent already exists. # Preserve live stream toggle if agent already exists.
if hasattr(self, "agent"): if hasattr(self, "agent"):
self.stream_enabled = self.agent.stream self.stream_enabled = self.agent.stream
self.client = cast("ChatClient", cast("object", create_client(self.provider))) self.client = cast("ChatClient", cast("object", create_client(self.provider)))
self.agent = self._make_agent() 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.sync_display_flags()
self._bind_todo_tools()
# Drop remote catalog when provider identity/url changed (not on model-only switch). # Drop remote catalog when provider identity/url changed (not on model-only switch).
if self._remote_models_key is not None and self._remote_models_key != self._models_cache_key(): if self._remote_models_key is not None and self._remote_models_key != self._models_cache_key():
self.invalidate_remote_models() self.invalidate_remote_models()
@@ -142,6 +217,29 @@ class ReplState:
self._remote_models_key = None self._remote_models_key = None
self._remote_models_error = None self._remote_models_error = None
def seed_remote_models(self, ids: list[str]) -> None:
"""Install a freshly fetched remote catalog into the session cache."""
self._remote_models = list(ids)
self._remote_models_fetched_at = time.monotonic()
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: def cached_remote_models(self) -> list[str] | None:
"""Return cached remote ids if still valid for the current provider.""" """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: if self._remote_models is None or self._remote_models_fetched_at is None:
@@ -258,6 +356,28 @@ class ReplState:
model=self.model, 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: def _try_set_provider(self, pname: str) -> bool:
import click import click
@@ -321,7 +441,11 @@ class ReplState:
model=self.model, model=self.model,
) )
self.session_id = session.sid 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.agent = self._make_agent()
self._bind_todo_tools()
async def rename_current_session(self, name: str) -> SessionRow: async def rename_current_session(self, name: str) -> SessionRow:
if self.session_id is None: if self.session_id is None:
@@ -372,11 +496,14 @@ class ReplState:
raise ValueError(msg) from exc raise ValueError(msg) from exc
self.session_id = session_id 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): if self.apply_session_llm(row):
self.rebuild_client() self.rebuild_client()
else: else:
self.agent = self._make_agent() self.agent = self._make_agent()
await self.agent.load_history() await self.agent.load_history()
await self.load_todo_stack()
async def resume_latest_or_new(self, name: str = "chat") -> str: async def resume_latest_or_new(self, name: str = "chat") -> str:
"""Resume most recently updated session for this workspace, or create one.""" """Resume most recently updated session for this workspace, or create one."""
@@ -391,6 +518,7 @@ class ReplState:
else: else:
self.agent = self._make_agent() self.agent = self._make_agent()
await self.agent.load_history() await self.agent.load_history()
await self.load_todo_stack()
_ = await self.memory.touch_session(latest.sid) _ = await self.memory.touch_session(latest.sid)
return "resume" return "resume"
@@ -398,6 +526,7 @@ class ReplState:
"""Soft-compact + model-summarize current history into a new workspace session. """Soft-compact + model-summarize current history into a new workspace session.
Returns ``(old_session_id, new_session_id, summary)``. Returns ``(old_session_id, new_session_id, summary)``.
Todo stack is carried into the new session.
""" """
from plyngent.agent.compact import build_compacted_seed_messages, summarize_messages from plyngent.agent.compact import build_compacted_seed_messages, summarize_messages
@@ -429,6 +558,7 @@ class ReplState:
system_prompt=self.config.agent_config.compact_system_prompt or None, system_prompt=self.config.agent_config.compact_system_prompt or None,
user_prefix=self.config.agent_config.compact_user_prefix or None, user_prefix=self.config.agent_config.compact_user_prefix or None,
) )
carried_todos = self.todo_stack.to_raw()
session_name = name or f"compact-from-{old_id}" session_name = name or f"compact-from-{old_id}"
await self.new_session(name=session_name) await self.new_session(name=session_name)
new_id = self.session_id new_id = self.session_id
@@ -442,7 +572,17 @@ class ReplState:
source_session_id=old_id, source_session_id=old_id,
seed_text=self.config.agent_config.compact_seed_text or None, seed_text=self.config.agent_config.compact_seed_text or None,
) )
self.agent.messages = list(seed)
for message in seed: for message in seed:
_ = await self.memory.append_message(new_id, message) _ = 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 return old_id, new_id, summary
+53
View File
@@ -200,6 +200,59 @@ class ConfigStore:
_ = self._bad_providers.pop(name, None) _ = self._bad_providers.pop(name, None)
return promoted 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 -- # -- persistence --
def write(self) -> None: def write(self) -> None:
+2
View File
@@ -32,6 +32,8 @@ class Session(PlyngentBase):
# Last selected provider/model for this session (config provider key + model id). # Last selected provider/model for this session (config provider key + model id).
provider_name: Mapped[str | None] = mapped_column(String(128), nullable=True) provider_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
model: Mapped[str | None] = mapped_column(String(256), nullable=True) model: Mapped[str | None] = mapped_column(String(256), nullable=True)
# Todo/task stack JSON for multi-step sub-tasks (optional).
todo_stack: Mapped[dict[str, object] | None] = mapped_column(JSON(), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+38 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Self from typing import TYPE_CHECKING, Self, cast
import msgspec import msgspec
from sqlalchemy import delete, select, text from sqlalchemy import delete, select, text
@@ -68,6 +68,7 @@ class MemoryStore:
_ = await conn.run_sync(PlyngentBase.metadata.create_all) _ = await conn.run_sync(PlyngentBase.metadata.create_all)
await conn.run_sync(_migrate_session_workspace) await conn.run_sync(_migrate_session_workspace)
await conn.run_sync(_migrate_session_llm) await conn.run_sync(_migrate_session_llm)
await conn.run_sync(_migrate_session_todo_stack)
async def close(self) -> None: async def close(self) -> None:
"""Dispose the underlying engine.""" """Dispose the underlying engine."""
@@ -208,6 +209,31 @@ class MemoryStore:
await session.refresh(row) await session.refresh(row)
return row return row
async def get_session_todo_stack(self, sid: int) -> dict[str, object] | None:
"""Return the stored todo stack JSON for *sid*, or None."""
async with self._session_factory() as session:
row = await session.get(Session, sid)
if row is None:
msg = f"session not found: {sid}"
raise ValueError(msg)
raw = row.todo_stack
if raw is None:
return None
return {str(k): v for k, v in cast("dict[object, object]", raw).items()}
async def update_session_todo_stack(self, sid: int, data: dict[str, object] | None) -> Session:
"""Persist todo stack JSON for a session (None clears)."""
async with self._session_factory() as session:
row = await session.get(Session, sid)
if row is None:
msg = f"session not found: {sid}"
raise ValueError(msg)
row.todo_stack = data
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return row
async def rename_session(self, sid: int, name: str) -> Session: async def rename_session(self, sid: int, name: str) -> Session:
"""Rename a session (max 64 characters, non-empty after strip).""" """Rename a session (max 64 characters, non-empty after strip)."""
cleaned = name.strip() cleaned = name.strip()
@@ -308,3 +334,14 @@ def _migrate_session_llm(sync_conn: object) -> None:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN provider_name VARCHAR(128)")) _ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN provider_name VARCHAR(128)"))
if "model" not in columns: if "model" not in columns:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN model VARCHAR(256)")) _ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN model VARCHAR(256)"))
def _migrate_session_todo_stack(sync_conn: object) -> None:
"""Add ``session.todo_stack`` JSON for the todo/task sub-task stack."""
from sqlalchemy.engine import Connection
if not isinstance(sync_conn, Connection):
return
columns = _session_columns(sync_conn)
if "todo_stack" not in columns:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN todo_stack JSON"))
+17 -1
View File
@@ -21,6 +21,17 @@ from .process import open_pty as open_pty
from .process import read_pty as read_pty from .process import read_pty as read_pty
from .process import run_command as run_command from .process import run_command as run_command
from .process import write_pty as write_pty from .process import write_pty as write_pty
from .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_TOOLS as VCS_TOOLS
from .vcs import vcs_branch as vcs_branch from .vcs import vcs_branch as vcs_branch
from .vcs import vcs_diff as vcs_diff from .vcs import vcs_diff as vcs_diff
@@ -31,14 +42,19 @@ from .workspace import (
DEFAULT_COMMAND_DENYLIST as DEFAULT_COMMAND_DENYLIST, DEFAULT_COMMAND_DENYLIST as DEFAULT_COMMAND_DENYLIST,
) )
from .workspace import WorkspaceError as WorkspaceError 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 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 clear_workspace_root as clear_workspace_root
from .workspace import get_command_denylist as get_command_denylist from .workspace import get_command_denylist as get_command_denylist
from .workspace import get_path_denylist as get_path_denylist from .workspace import get_path_denylist as get_path_denylist
from .workspace import get_workspace_root as get_workspace_root 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 resolve_path as resolve_path
from .workspace import set_command_denylist as set_command_denylist from .workspace import set_command_denylist as set_command_denylist
from .workspace import set_path_denylist as set_path_denylist from .workspace import set_path_denylist as set_path_denylist
from .workspace import set_workspace_root as set_workspace_root from .workspace import set_workspace_root as set_workspace_root
DEFAULT_TOOLS = [*FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS] DEFAULT_TOOLS = [*FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS, *TODO_TOOLS]
+2 -2
View File
@@ -4,9 +4,9 @@ from plyngent.agent import tool
from plyngent.prompting import NonInteractiveError, ask_async from plyngent.prompting import NonInteractiveError, ask_async
@tool @tool(name="ask_user_line")
async def ask_user(question: str, default: str = "") -> str: async def ask_user(question: str, default: str = "") -> str:
"""Ask the human a free-form question and return their answer. """Ask the human a free-form one-line question and return their answer.
Always allows arbitrary text. Use for clarifying requirements, preferences, Always allows arbitrary text. Use for clarifying requirements, preferences,
or any input that is not a fixed menu. Optional ``default`` is used if the or any input that is not a fixed menu. Optional ``default`` is used if the
+2 -2
View File
@@ -46,7 +46,7 @@ def parse_options(raw: str) -> list[ChoiceOption]:
return out return out
@tool @tool(name="ask_user_choice")
async def choose_user( async def choose_user(
question: str, question: str,
options: str, options: str,
@@ -54,7 +54,7 @@ async def choose_user(
*, *,
allow_custom: bool = True, allow_custom: bool = True,
) -> str: ) -> str:
"""Ask the human to pick from options (or type a custom answer). """Ask the human to pick from a list of options (or type a custom answer).
``options`` is a JSON array of strings, or objects with ``options`` is a JSON array of strings, or objects with
``label``, optional ``description``, optional ``value``. ``label``, optional ``description``, optional ``value``.
+2 -2
View File
@@ -54,13 +54,13 @@ def parse_fields(raw: str) -> list[FormField]:
return out return out
@tool @tool(name="ask_user_form")
async def form_user(title: str, fields: str, *, confirm_submit: bool = True) -> str: async def form_user(title: str, fields: str, *, confirm_submit: bool = True) -> str:
"""Run a multi-step form with the human; returns JSON object of answers. """Run a multi-step form with the human; returns JSON object of answers.
``fields`` is a JSON array of objects: ``fields`` is a JSON array of objects:
``name``, ``prompt``, optional ``default``, optional ``options`` (same shape ``name``, ``prompt``, optional ``default``, optional ``options`` (same shape
as choose_user), optional ``allow_custom`` (default true). as ask_user_choice), optional ``allow_custom`` (default true).
When ``confirm_submit`` is true, the human reviews a summary before submit. When ``confirm_submit`` is true, the human reviews a summary before submit.
""" """
try: try:
+172 -24
View File
@@ -1,11 +1,111 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, cast
from plyngent.tools.workspace import WorkspaceError, resolve_path from plyngent.tools.workspace import WorkspaceError, resolve_path
if TYPE_CHECKING: 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: 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: try:
target = resolve_path(path) target = resolve_path(path)
except WorkspaceError: except WorkspaceError:
return None return f"write file {path!r}"
if target.exists() or target.is_symlink(): return f"write file {path!r} ({target})"
return f"overwrite existing file {path!r}"
return None
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``. """Return a short reason if ``name``/``args`` need user confirm, else ``None``.
Hard denylists (paths/commands) still raise independently. This only covers 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] = { if name == "delete_path":
"delete_path": ( return _delete_path_reason(args)
f"delete path {args.get('path', '')!r} recursively" if name == "move_path":
if bool(args.get("recursive", False)) return _move_path_reason(args)
else f"delete path {args.get('path', '')!r}" if name == "copy_path":
), return _copy_path_reason(args)
"move_path": f"move {args.get('src', '')!r} -> {args.get('dst', '')!r}", if name == "write_file":
"copy_path": ( return _write_file_reason(args)
f"copy with overwrite {args.get('src', '')!r} -> {args.get('dst', '')!r}" if name == "edit_replace":
if bool(args.get("overwrite", False)) return _edit_replace_reason(args)
else None if name == "edit_lineno":
), return _edit_lineno_reason(args)
"write_file": _write_file_reason(args), if name == "run_command":
} return _run_command_reason(args)
if name not in reasons: if name == "open_pty":
return _open_pty_reason(args)
return None return None
return reasons[name]
+3
View File
@@ -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_lineno import edit_lineno as edit_lineno
from .edit_replace import edit_replace as edit_replace from .edit_replace import edit_replace as edit_replace
from .fs_ops import copy_path as copy_path from .fs_ops import copy_path as copy_path
@@ -22,4 +24,5 @@ FILE_TOOLS = [
copy_path, copy_path,
move_path, move_path,
delete_path, delete_path,
new_temporary_workspace,
] ]
+26 -2
View File
@@ -3,12 +3,33 @@ from __future__ import annotations
from plyngent.agent import tool from plyngent.agent import tool
from plyngent.tools.workspace import resolve_path 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 @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. """Read a text file under the workspace.
``offset`` is 0-based line start; ``limit`` is max lines (None = rest of file). ``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) target = resolve_path(path)
if not target.is_file(): 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) end = len(lines) if limit is None else min(len(lines), start + limit)
if start >= len(lines): if start >= len(lines):
return "" 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)
+71 -12
View File
@@ -4,9 +4,10 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from plyngent.agent import tool 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: if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path from pathlib import Path
DEFAULT_MAX_DEPTH = 4 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) @dataclass(frozen=True, slots=True)
class _TreeLimits: class _TreeLimits:
max_depth: int max_depth: int
max_entries: int max_entries: int
skip_hidden_dirs: bool skip_hidden_dirs: bool
skip_basenames: frozenset[str]
apply_path_denylist: bool
def _skip_directory(name: str, *, skip_hidden_dirs: bool) -> bool: def _skip_directory(name: str, *, limits: _TreeLimits) -> bool:
if name in VCS_DIR_NAMES: if name in VCS_DIR_NAMES or name in limits.skip_basenames:
return True 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: try:
children = list(directory.iterdir()) children = list(directory.iterdir())
except OSError as exc: 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() is_dir = child.is_dir()
except OSError: except OSError:
continue 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 continue
visible.append(child) visible.append(child)
# Directories first, then files; alphabetical within each group. # Directories first, then files; alphabetical within each group.
@@ -70,7 +110,7 @@ def _render_tree(
if depth >= limits.max_depth: if depth >= limits.max_depth:
return return
children = _list_children(directory, skip_hidden_dirs=limits.skip_hidden_dirs) children = _list_children(directory, limits=limits)
if isinstance(children, str): if isinstance(children, str):
lines.append(f"{prefix}{children}") lines.append(f"{prefix}{children}")
return return
@@ -104,6 +144,13 @@ def _render_tree(
lines.append(f"{prefix}└── … ({more} more entries not shown)") 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 @tool
def tree( def tree(
path: str = ".", path: str = ".",
@@ -111,13 +158,22 @@ def tree(
max_depth: int = DEFAULT_MAX_DEPTH, max_depth: int = DEFAULT_MAX_DEPTH,
max_entries: int = DEFAULT_MAX_ENTRIES, max_entries: int = DEFAULT_MAX_ENTRIES,
skip_hidden_dirs: bool = True, skip_hidden_dirs: bool = True,
skip_dirs: list[str] | None = None,
apply_path_denylist: bool = True,
) -> str: ) -> str:
"""Show a directory tree under the workspace. """Show a directory tree under the workspace.
Always skips VCS metadata directories (``.git``, ``.hg``, ``.svn``, …). 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 By default skips other dot-directories (not hidden files). Use
``skip_hidden_dirs=false`` to include them. ``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_depth`` limits how deep directories are expanded (1 = origin + children).
``max_entries`` caps how many entries are listed per directory. ``max_entries`` caps how many entries are listed per directory.
""" """
@@ -135,15 +191,18 @@ def tree(
root_label = path.rstrip("/\\") or "." root_label = path.rstrip("/\\") or "."
lines = [f"{root_label}/"] lines = [f"{root_label}/"]
_render_tree(
origin,
prefix="",
depth=0,
limits = _TreeLimits( limits = _TreeLimits(
max_depth=max_depth, max_depth=max_depth,
max_entries=max_entries, max_entries=max_entries,
skip_hidden_dirs=skip_hidden_dirs, 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, lines=lines,
) )
return "\n".join(lines) return "\n".join(lines)
+4 -1
View File
@@ -1,7 +1,10 @@
from .close_pty import close_pty as close_pty from .close_pty import close_pty as close_pty
from .open_pty import open_pty as open_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 .read_pty import read_pty as read_pty
from .run_command import run_command as run_command from .run_command import run_command as run_command
from .write_pty import write_pty as write_pty 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 typing import TYPE_CHECKING, ClassVar
from plyngent.tools.process.pty_backend import PtyHandle, pty_available, spawn_pty 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 from plyngent.tools.workspace import WorkspaceError, check_command_allowed, resolve_path
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -286,6 +287,8 @@ class PtyManager:
truncated = len(data) > max_bytes truncated = len(data) > max_bytes
if truncated: if truncated:
data = data[:max_bytes] 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) budget_exhausted = cls._maybe_raise_budget(session)
return PtyReadResult( return PtyReadResult(
session_id=session_id, session_id=session_id,
@@ -354,6 +357,8 @@ class PtyManager:
with cls._lock: with cls._lock:
_ = cls._sessions.pop(session_id, None) _ = 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( return PtyCloseResult(
session_id=session_id, session_id=session_id,
closed=True, closed=True,
@@ -381,6 +386,7 @@ class PtyManager:
@classmethod @classmethod
def close_all(cls) -> None: def close_all(cls) -> None:
"""Close every open session (no host TTY reset)."""
with cls._lock: with cls._lock:
ids = list(cls._sessions.keys()) ids = list(cls._sessions.keys())
for session_id in ids: for session_id in ids:
+108
View File
@@ -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)
+19 -10
View File
@@ -6,22 +6,31 @@ from plyngent.tools.workspace import WorkspaceError
from .pty_session import PtyManager from .pty_session import PtyManager
@tool def write_pty_payload(session_id: int, raw: str) -> str:
def write_pty(session_id: int, data: str) -> str: """Write raw bytes (as str) to the PTY and format the tool status string."""
"""Write text to a PTY session (interactive input). Does not append a newline.""" PtyManager.write(session_id, raw)
try:
PtyManager.write(session_id, data)
session = PtyManager.refresh(session_id) 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) exit_disp = "" if session.exit_code is None else str(session.exit_code)
return "\n".join( return "\n".join(
[ [
f"session_id={session_id}", f"session_id={session_id}",
f"alive={'true' if session.alive else 'false'}", f"alive={'true' if session.alive else 'false'}",
f"exit_code={exit_disp}", 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}"
+96
View File
@@ -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"
)
+126
View File
@@ -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]
+82 -6
View File
@@ -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): class WorkspaceError(ValueError):
"""Raised when a path or command violates workspace policy.""" """Raised when a path or command violates workspace policy."""
@@ -35,6 +38,14 @@ class _WorkspaceState:
root: Path | None = None root: Path | None = None
path_denylist: tuple[str, ...] = () path_denylist: tuple[str, ...] = ()
command_denylist: frozenset[str] = DEFAULT_COMMAND_DENYLIST 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() _state = _WorkspaceState()
@@ -59,7 +70,7 @@ def get_workspace_root() -> Path:
def clear_workspace_root() -> None: def clear_workspace_root() -> None:
"""Clear workspace root (mainly for tests).""" """Clear workspace root (mainly for tests). Does not clear allowlist."""
_state.root = None _state.root = None
@@ -82,18 +93,83 @@ def get_command_denylist() -> frozenset[str]:
return _state.command_denylist 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: 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() root = get_workspace_root()
candidate = Path(path) candidate = Path(path)
if not candidate.is_absolute(): if not candidate.is_absolute():
candidate = root / candidate candidate = root / candidate
resolved = candidate.expanduser().resolve() resolved = candidate.expanduser().resolve()
try: if not _under_any_root(resolved):
_ = resolved.relative_to(root)
except ValueError as exc:
msg = f"path escapes workspace root ({root}): {path}" 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. # Normalize separators so denylist entries like ``/secrets/`` match on Windows.
resolved_str = str(resolved).replace("\\", "/") resolved_str = str(resolved).replace("\\", "/")
for pattern in _state.path_denylist: for pattern in _state.path_denylist:
+10
View File
@@ -56,4 +56,14 @@ async def test_confirm_deny() -> None:
async def test_no_hooks_skips_confirm() -> None: async def test_no_hooks_skips_confirm() -> None:
registry = ToolRegistry([delete_path]) registry = ToolRegistry([delete_path])
assert registry.soft_confirm is False
assert await registry.execute("delete_path", '{"path": "a.txt"}') == "deleted a.txt" assert await registry.execute("delete_path", '{"path": "a.txt"}') == "deleted a.txt"
async def test_soft_confirm_property() -> None:
def on_confirm(name: str, args: Mapping[str, object], reason: str) -> bool:
del name, args, reason
return True
gated = ToolRegistry([delete_path], danger=classify_danger, on_confirm=on_confirm)
assert gated.soft_confirm is True
+141 -2
View File
@@ -30,6 +30,7 @@ from plyngent.lmproto.openai_compatible.model import (
ChatCompletionsParam, ChatCompletionsParam,
ChunkChoice, ChunkChoice,
DeltaMessage, DeltaMessage,
FinishReason,
StreamFunctionDelta, StreamFunctionDelta,
StreamToolCallDelta, StreamToolCallDelta,
ToolChatMessage, 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 tool_calls = message.tool_calls
if tool_calls is not UNSET and tool_calls: if tool_calls is not UNSET and tool_calls:
deltas: list[StreamToolCallDelta] = [] deltas: list[StreamToolCallDelta] = []
@@ -152,7 +175,8 @@ class ScriptedClient:
def _response( def _response(
message: AssistantChatMessage, message: AssistantChatMessage,
*, *,
usage: dict[str, int] | None = None, usage: dict[str, object] | None = None,
finish_reason: FinishReason | None = "stop",
) -> ChatCompletionResponse: ) -> ChatCompletionResponse:
return ChatCompletionResponse( return ChatCompletionResponse(
id="1", id="1",
@@ -164,7 +188,7 @@ def _response(
index=0, index=0,
message=message, message=message,
logprobs={}, logprobs={},
finish_reason="stop", finish_reason=finish_reason,
) )
], ],
system_fingerprint="", system_fingerprint="",
@@ -825,3 +849,118 @@ async def test_tool_result_char_budget() -> None:
assert len(tool_msgs) == 1 assert len(tool_msgs) == 1
assert tool_msgs[0].content.startswith("x" * 20) assert tool_msgs[0].content.startswith("x" * 20)
assert "truncated" in tool_msgs[0].content 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)
+26
View File
@@ -8,6 +8,7 @@ from plyngent.agent.responses_bridge import (
chat_param_to_responses_kwargs, chat_param_to_responses_kwargs,
response_to_assistant_message, response_to_assistant_message,
response_to_chat_completion, response_to_chat_completion,
responses_status_to_finish_reason,
tool_items_to_response_tools, tool_items_to_response_tools,
) )
from plyngent.agent.responses_client import ResponsesChatClient 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 completion.choices[0].message.content == "done"
assert isinstance(completion.usage, dict) assert isinstance(completion.usage, dict)
assert completion.usage["input_tokens"] == 10 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: def test_chat_param_to_responses_kwargs() -> None:
+238
View File
@@ -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)
+52
View File
@@ -99,11 +99,63 @@ async def test_compact_to_new_session(tmp_path: Path) -> None:
assert "compacted goals" in summary assert "compacted goals" in summary
loaded = await memory.list_messages(new_id) loaded = await memory.list_messages(new_id)
assert any("compacted goals" in getattr(m, "content", "") for m in loaded) 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 # Old session still exists and is listable
sessions = await memory.list_sessions(workspace=tmp_path) sessions = await memory.list_sessions(workspace=tmp_path)
ids = {s.sid for s in sessions} ids = {s.sid for s in sessions}
assert old_id in ids assert old_id in ids
assert new_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: finally:
await memory.close() await memory.close()
+34
View File
@@ -41,9 +41,43 @@ async def test_render_reasoning_and_text(capsys: pytest.CaptureFixture[str]) ->
assert "think" in out assert "think" in out
assert "assistant:" in out assert "assistant:" in out
assert "hello" in out assert "hello" in out
# Labels on their own lines (content begins after newline).
assert "assistant:\nhello" in out or "assistant:\r\nhello" in out
set_markdown_enabled(True) set_markdown_enabled(True)
async def test_flush_markdown_on_source_change(
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Assistant segment before tools is flushed so later text is a new segment."""
monkeypatch.setattr("plyngent.cli.display.markdown_render_available", lambda: True)
set_markdown_enabled(True)
from plyngent.agent import ToolCallEvent
from plyngent.lmproto.openai_compatible.model import (
AssistantFunctionTool,
AssistantFunctionToolCall,
)
call = AssistantFunctionToolCall(
id="1",
function=AssistantFunctionTool(name="read_file", arguments="{}"),
)
await render_events(
_aiter(
[
TextDeltaEvent(content="before **tool**"),
ToolCallEvent(tool_call=call),
TextDeltaEvent(content="after"),
]
),
markdown=True,
)
out = capsys.readouterr().out
assert "[tool]" in out
assert "after" in out
async def test_tool_result_preview_vs_verbose(capsys: pytest.CaptureFixture[str]) -> None: async def test_tool_result_preview_vs_verbose(capsys: pytest.CaptureFixture[str]) -> None:
long = "x" * 200 long = "x" * 200
msg = ToolChatMessage(content=long, tool_call_id="1") msg = ToolChatMessage(content=long, tool_call_id="1")
+67 -2
View File
@@ -1,16 +1,22 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import signal
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import pytest
from plyngent.cli.interrupt import ( from plyngent.cli.interrupt import (
allow_task_cancel, allow_task_cancel,
pause_task_cancel_for_prompt, pause_task_cancel_for_prompt,
run_in_prompt_thread, run_in_prompt_thread,
set_sigint_reinstall,
) )
from plyngent.cli.limits import prompt_continue_limit, prompt_continue_limit_async from plyngent.cli.limits import prompt_continue_limit, prompt_continue_limit_async
from plyngent.cli.retry import run_cancellable
if TYPE_CHECKING: if TYPE_CHECKING:
import pytest pass
def test_pause_task_cancel_for_prompt() -> None: 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 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 test_prompt_continue_limit_under_pause(monkeypatch: pytest.MonkeyPatch) -> None:
def _confirm(*_a: object, **_k: object) -> bool: def _confirm(*_a: object, **_k: object) -> bool:
assert allow_task_cancel() is False assert allow_task_cancel() is False
@@ -36,7 +52,6 @@ async def test_run_in_prompt_thread_pauses_cancel() -> None:
def work() -> str: def work() -> str:
return "ok" return "ok"
# ContextVar may not propagate to worker threads; assert pause around the call.
result = await run_in_prompt_thread(work) result = await run_in_prompt_thread(work)
assert result == "ok" assert result == "ok"
assert allow_task_cancel() is True 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) monkeypatch.setattr("click.confirm", _confirm)
assert await prompt_continue_limit_async("too many rounds") is True 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)
+17 -1
View File
@@ -1,6 +1,11 @@
from __future__ import annotations 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.prompting import get_prompt_backend, temporary_backend
from plyngent.tools.process.pty_session import PtyManager from plyngent.tools.process.pty_session import PtyManager
from tests.test_prompting import ScriptedBackend 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 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: def test_install_cli_limit_hooks() -> None:
install_cli_limit_hooks() install_cli_limit_hooks()
assert callable(getattr(PtyManager, "_limit_continue", None)) assert callable(getattr(PtyManager, "_limit_continue", None))
+7 -2
View File
@@ -12,10 +12,14 @@ from plyngent.cli.models_source import (
from plyngent.config.models import ModelConfig, OpenAICompatibleProvider from plyngent.config.models import ModelConfig, OpenAICompatibleProvider
def test_merge_model_choices_union() -> None: def test_merge_model_choices_remote_first() -> None:
assert merge_model_choices(["b", "a"], ["a", "c"]) == ["a", "b", "c"] # remote-first: remote sorted, then config-only
assert merge_model_choices(["b", "a"], ["a", "c"]) == ["a", "c", "b"]
assert merge_model_choices(["a"], None) == ["a"] assert merge_model_choices(["a"], None) == ["a"]
assert merge_model_choices([], ["z"]) == ["z"] assert merge_model_choices([], ["z"]) == ["z"]
assert merge_model_choices(["cfg"], ["remote", "cfg"], prefer="remote") == ["cfg", "remote"]
assert merge_model_choices(["b", "a"], ["a", "c"], prefer="union") == ["a", "b", "c"]
assert merge_model_choices(["b", "a"], ["a", "c"], prefer="config") == ["a", "b", "c"]
def test_model_choices_for_provider() -> None: def test_model_choices_for_provider() -> None:
@@ -26,6 +30,7 @@ def test_model_choices_for_provider() -> None:
) )
assert config_model_ids(provider) == ["cfg"] assert config_model_ids(provider) == ["cfg"]
assert model_choices_for_provider(provider, remote_ids=["remote", "cfg"]) == ["cfg", "remote"] assert model_choices_for_provider(provider, remote_ids=["remote", "cfg"]) == ["cfg", "remote"]
assert model_choices_for_provider(provider, remote_ids=["remote"]) == ["remote", "cfg"]
def test_client_supports_models() -> None: def test_client_supports_models() -> None:
+19
View File
@@ -181,3 +181,22 @@ def test_complete_slash_args_from_registry(tmp_path: object) -> None:
assert complete_slash_args(state, "/model", "a") == ["alpha"] assert complete_slash_args(state, "/model", "a") == ["alpha"]
assert complete_slash_args(state, "/export", "j") == ["json"] assert complete_slash_args(state, "/export", "j") == ["json"]
assert complete_slash_args(state, "/help", "st") == ["status", "stream"] 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"]
+143 -2
View File
@@ -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: async def test_help_history_no_fake_options(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
assert await handle_slash(state, "/help history") is True assert await handle_slash(state, "/help history") is True
out = capsys.readouterr().out out = capsys.readouterr().out
assert "Usage: /history [N]" in out assert "Usage: /history" in out
assert "Options:" not 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 assert " --help" not in out
@@ -341,6 +343,93 @@ async def test_verbose_toggle(state: ReplState) -> None:
assert get_verbose_tool_results() is False 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
assert await handle_slash(state, "/yolo") is True
assert "yolo=off" in capsys.readouterr().out
assert await handle_slash(state, "/yolo on") is True
assert state.effective_yolo() == "on"
assert state.soft_confirm_enabled() is False
assert "yolo=on" in capsys.readouterr().out
assert await handle_slash(state, "/yolo once") is True
assert state.effective_yolo() == "once"
assert state.soft_confirm_enabled() is False
state.expire_yolo_once(quiet=True)
assert state.effective_yolo() == "off"
assert state.soft_confirm_enabled() is True
assert await handle_slash(state, "/yolo once") is True
state.expire_yolo_once()
err = capsys.readouterr().err
assert "yolo=off (once expired)" in err
assert state.effective_yolo() == "off"
async def test_yolo_rebuilds_tool_registry(tmp_path: Path) -> None:
_ = set_workspace_root(tmp_path)
memory = await MemoryStore.open(DatabaseConfig())
provider = OpenAIProvider(access_key_or_token="sk-test")
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
config.providers = {"local": provider}
st = ReplState(
config=config,
memory=memory,
workspace=tmp_path,
provider_name="local",
provider=provider,
model="gpt-test",
tools_enabled=True,
)
try:
assert st.agent.tools is not None
assert st.agent.tools.soft_confirm is True
st.set_yolo("on")
assert st.agent.tools is not None
assert st.agent.tools.soft_confirm is False
st.set_yolo("once")
assert st.agent.tools is not None
assert st.agent.tools.soft_confirm is False
st.set_yolo("off")
assert st.agent.tools is not None
assert st.agent.tools.soft_confirm is True
finally:
await memory.close()
async def test_resume(state: ReplState) -> None: async def test_resume(state: ReplState) -> None:
sid = state.session_id sid = state.session_id
assert sid is not None assert sid is not None
@@ -357,10 +446,61 @@ async def test_history(state: ReplState, capsys: pytest.CaptureFixture[str]) ->
] ]
assert await handle_slash(state, "/history") is True assert await handle_slash(state, "/history") is True
out = capsys.readouterr().out out = capsys.readouterr().out
assert "mode=preview" in out
assert "user: hello" in out assert "user: hello" in out
assert "assistant: hi there" 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: async def test_rounds(state: ReplState) -> None:
assert await handle_slash(state, "/rounds 40") is True assert await handle_slash(state, "/rounds 40") is True
assert state.max_rounds == 40 assert state.max_rounds == 40
@@ -378,6 +518,7 @@ async def test_status_shows_context_tokens(state: ReplState, capsys: pytest.Capt
assert "(est)" in out # no API usage yet assert "(est)" in out # no API usage yet
assert "context_chars=" in out assert "context_chars=" in out
assert "tool_result_max=" in out assert "tool_result_max=" in out
assert "yolo=off" in out
assert str(state.workspace) in out assert str(state.workspace) in out
state.agent.last_request_usage = TokenUsage( state.agent.last_request_usage = TokenUsage(
+19 -1
View File
@@ -5,7 +5,14 @@ from typing import TYPE_CHECKING, Literal, overload
import pytest import pytest
from plyngent.agent import ChatAgent from plyngent.agent import ChatAgent
from plyngent.cli.retry import retry_pending_with_retries, run_turn_with_retries, sleep_cancellable from plyngent.cli.retry import (
DEFAULT_MAX_AUTO_RETRIES,
DEFAULT_RETRY_DELAYS_SECONDS,
default_retry_delays,
retry_pending_with_retries,
run_turn_with_retries,
sleep_cancellable,
)
from plyngent.config.models import DatabaseConfig from plyngent.config.models import DatabaseConfig
from plyngent.lmproto.openai_compatible.model import ( from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage, AssistantChatMessage,
@@ -23,6 +30,17 @@ if TYPE_CHECKING:
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
def test_default_retry_delays_schedule() -> None:
assert DEFAULT_MAX_AUTO_RETRIES == 10
assert default_retry_delays(0) == ()
assert default_retry_delays(4) == (5.0, 10.0, 15.0, 20.0)
delays = default_retry_delays(10)
assert delays[:4] == (5.0, 10.0, 15.0, 20.0)
assert delays[4:] == (30.0, 40.0, 50.0, 60.0, 70.0, 80.0)
assert delays == DEFAULT_RETRY_DELAYS_SECONDS
assert len(DEFAULT_RETRY_DELAYS_SECONDS) == 10
def _response(message: AssistantChatMessage) -> ChatCompletionResponse: def _response(message: AssistantChatMessage) -> ChatCompletionResponse:
return ChatCompletionResponse( return ChatCompletionResponse(
id="1", id="1",
+33
View File
@@ -179,6 +179,39 @@ def test_write_new_config() -> None:
assert config.providers["foo2"].access_key_or_token == "sk-00301212" 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: def test_update_config() -> None:
file = Path(__file__).parent / "plyngent-edit-2.toml" file = Path(__file__).parent / "plyngent-edit-2.toml"
_ = shutil.copy(Path(__file__).parent / "plyngent-valid.toml", file) _ = shutil.copy(Path(__file__).parent / "plyngent-valid.toml", file)
+7 -1
View File
@@ -4,7 +4,11 @@ from typing import TYPE_CHECKING
import pytest 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: if TYPE_CHECKING:
from collections.abc import Iterator from collections.abc import Iterator
@@ -14,6 +18,8 @@ if TYPE_CHECKING:
@pytest.fixture @pytest.fixture
def workspace(tmp_path: Path) -> Iterator[Path]: def workspace(tmp_path: Path) -> Iterator[Path]:
clear_workspace_root() clear_workspace_root()
clear_workspace_allowlist()
root = set_workspace_root(tmp_path) root = set_workspace_root(tmp_path)
yield root yield root
clear_workspace_allowlist()
clear_workspace_root() clear_workspace_root()
+6 -6
View File
@@ -12,7 +12,7 @@ async def test_ask_user_tool() -> None:
backend = ScriptedBackend(["42"]) backend = ScriptedBackend(["42"])
with temporary_backend(backend): with temporary_backend(backend):
registry = ToolRegistry([ask_user]) registry = ToolRegistry([ask_user])
out = await registry.execute("ask_user", '{"question": "Answer?"}') out = await registry.execute("ask_user_line", '{"question": "Answer?"}')
assert out == "42" assert out == "42"
@@ -21,7 +21,7 @@ async def test_choose_user_tool_index() -> None:
with temporary_backend(backend): with temporary_backend(backend):
registry = ToolRegistry([choose_user]) registry = ToolRegistry([choose_user])
out = await registry.execute( out = await registry.execute(
"choose_user", "ask_user_choice",
json.dumps( json.dumps(
{ {
"question": "Pick", "question": "Pick",
@@ -36,7 +36,7 @@ async def test_choose_user_tool_index() -> None:
async def test_choose_user_bad_options() -> None: async def test_choose_user_bad_options() -> None:
registry = ToolRegistry([choose_user]) registry = ToolRegistry([choose_user])
out = await registry.execute( out = await registry.execute(
"choose_user", "ask_user_choice",
json.dumps({"question": "Pick", "options": "not-json"}), json.dumps({"question": "Pick", "options": "not-json"}),
) )
assert out.startswith("error:") assert out.startswith("error:")
@@ -48,7 +48,7 @@ async def test_form_user_tool() -> None:
with temporary_backend(backend): with temporary_backend(backend):
registry = ToolRegistry([form_user]) registry = ToolRegistry([form_user])
out = await registry.execute( out = await registry.execute(
"form_user", "ask_user_form",
json.dumps({"title": "Setup", "fields": fields, "confirm_submit": True}), json.dumps({"title": "Setup", "fields": fields, "confirm_submit": True}),
) )
assert json.loads(out) == {"user": "ncbm"} assert json.loads(out) == {"user": "ncbm"}
@@ -56,11 +56,11 @@ async def test_form_user_tool() -> None:
async def test_chat_tools_in_default_list() -> None: async def test_chat_tools_in_default_list() -> None:
names = {t.name for t in CHAT_TOOLS} names = {t.name for t in CHAT_TOOLS}
assert names == {"ask_user", "choose_user", "form_user"} assert names == {"ask_user_line", "ask_user_choice", "ask_user_form"}
async def test_ask_user_non_interactive_error() -> None: async def test_ask_user_non_interactive_error() -> None:
with temporary_backend(NonInteractiveBackend()): with temporary_backend(NonInteractiveBackend()):
registry = ToolRegistry([ask_user]) registry = ToolRegistry([ask_user])
out = await registry.execute("ask_user", '{"question": "hi"}') out = await registry.execute("ask_user_line", '{"question": "hi"}')
assert out.startswith("error:") assert out.startswith("error:")
+61 -8
View File
@@ -11,18 +11,71 @@ def test_classify_delete_and_move() -> None:
assert "move" in (classify_danger("move_path", {"src": "a", "dst": "b"}) or "") assert "move" in (classify_danger("move_path", {"src": "a", "dst": "b"}) or "")
def test_classify_copy_overwrite_only() -> None: def test_classify_copy() -> None:
assert classify_danger("copy_path", {"src": "a", "dst": "b"}) is None assert "copy" in (classify_danger("copy_path", {"src": "a", "dst": "b"}) or "")
assert "overwrite" in (classify_danger("copy_path", {"src": "a", "dst": "b", "overwrite": True}) or "")
def test_classify_write_file_overwrite(workspace: object) -> None: def test_classify_write_and_edits(tmp_path: Path) -> None:
assert isinstance(workspace, Path) from plyngent.tools.workspace import clear_workspace_root, set_workspace_root
_ = (workspace / "x.txt").write_text("old", encoding="utf-8")
assert "overwrite" in (classify_danger("write_file", {"path": "x.txt", "content": "n"}) or "") set_workspace_root(tmp_path)
assert classify_danger("write_file", {"path": "new.txt", "content": "n"}) is None 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: def test_classify_safe_tools() -> None:
assert classify_danger("read_file", {"path": "a"}) is 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("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
+12
View File
@@ -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" 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: def test_listdir_missing(workspace: object) -> None:
del workspace del workspace
assert "error" in call_sync(listdir, "nope") assert "error" in call_sync(listdir, "nope")
+87 -1
View File
@@ -4,7 +4,14 @@ import asyncio
import sys import sys
from pathlib import Path 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.process.pty_session import PtyManager
from plyngent.tools.workspace import set_command_denylist from plyngent.tools.workspace import set_command_denylist
from tests.test_tools.helpers import call_async, call_sync 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() 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: def test_pty_backend_available() -> None:
from plyngent.tools.process.pty_backend import pty_available from plyngent.tools.process.pty_backend import pty_available
+64
View File
@@ -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()
+55
View File
@@ -83,3 +83,58 @@ def test_tree_invalid_limits(workspace: object) -> None:
del workspace del workspace
assert "max_depth" in call_sync(tree, ".", max_depth=0) assert "max_depth" in call_sync(tree, ".", max_depth=0)
assert "max_entries" in call_sync(tree, ".", max_entries=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)