48 Commits

Author SHA1 Message Date
NCBM 614ffaaf8f ci: publish only after CI check succeeds
Merge release into ci.yml: tag pushes run check then publish (needs:
check). Remove standalone python-publish so PyPI never ships on red CI.
Retag (delete+push v*) still retriggers the pipeline.
2026-07-20 04:44:57 +08:00
NCBM 7bdd9ff8b7 chore: pin ruff and basedpyright for prek and lockfile
Match prek additional_dependencies and pyproject dev pins to pdm.lock
(ruff==0.15.22, basedpyright==1.39.9). Format fix for ruff 0.15.22.
2026-07-20 04:35:26 +08:00
NCBM bec14e76af release: plyngent 0.1.3
Bump package version; refresh lock (ruff 0.15.22, platformdirs 4.10.1).
prek.toml already tracks latest ruff/basedpyright via additional_dependencies.
2026-07-20 04:30:07 +08:00
NCBM 59017fd61b core/agent: synthetic_tool nag uses real todo_list render body
developer/user keep OPEN WORK prose; synthetic_tool injects forged
todo_list call with stack.render() only so the result matches a real
tool output.
2026-07-20 04:10:21 +08:00
NCBM 2e6cb3834a core/agent: drop system todo nag; emit events for synthetic_tool
Remove system strategy (legacy maps to developer). synthetic_tool still
forges the call but uses real stack text as the tool result, and now
yields ToolCall/Result events so the CLI flushes and does not glue nag
text to the next assistant stream.
2026-07-20 03:55:50 +08:00
NCBM f6d14ed549 core/agent: configurable todo nag strategies
Add [agent] todo_nag_strategy: developer (default), system, user,
synthetic_tool (forged todo_list pair), or none. Shared inject helper
used for turn-start and end-of-turn nags; CLI wires agent config.
2026-07-20 03:33:02 +08:00
NCBM a4ab11c9a2 core/agent: stronger open-work signals in todo stack prompts
Split tags: [TODO OPEN WORK] for pending/in_progress (undone work) vs
[TODO HYGIENE] for all-terminal leftover groups. End-of-turn review lists
open titles; tool docs stress open items as unfinished work.
2026-07-20 03:17:44 +08:00
NCBM 0d783b6d96 core/tools: claim PTY controlling TTY after setsid (sudo without -S)
After setsid(), ioctl(TIOCSCTTY) (or reopen slave) so the session leader
has a real controlling terminal. Live-tested: open_pty([sudo,echo,test])
shows a password prompt; ask_into_pty + read_pty yields test with exit 0.
2026-07-20 03:15:56 +08:00
NCBM d6616d0da3 core/tools: timed human allow for denylisted commands
Denylist hits ask the user (default deny, timeout) instead of only hard
rejecting when a policy hook is installed. Independent of YOLO soft-confirm;
session grants skip re-prompt for the same basename. CLI installs the hook
via install_cli_limit_hooks.
2026-07-20 03:02:34 +08:00
NCBM a677e06af4 core/tools: add ask_into_pty for human-local PTY input
Prompt the human (optional no-echo secret) and write only to the PTY.
Tool results never include the answer so secrets stay off external APIs.
Empty/Ctrl-C cancel without writing; submit defaults to trailing newline.
2026-07-20 02:47:19 +08:00
NCBM aadcd237db core/cli: soft-fail GET /models cancel/timeout on provider switch
Map CancelledError and TimeoutError from models() to RuntimeError so
merged_model_choices falls back to config/cache. Stub remote fetch in
/provider unit tests so they never hit the network.
2026-07-19 22:38:22 +08:00
NCBM 58ff646034 core/cli: config open via VISUAL/EDITOR or system default
Prefer $VISUAL then $EDITOR (blocking). Without either, fall back to
xdg-open / open / startfile (non-blocking) for config paths only.
/edit stays blocking-only so the temp buffer can be re-read.
2026-07-19 22:33:02 +08:00
NCBM d528f65c4c core/tools: edit_replace max_replaces and richer match messages
Default still replaces first match only. Report how many matches were
found, how many replaced, and remaining hits so agents can raise
max_replaces or narrow old_string.
2026-07-19 21:55:16 +08:00
NCBM 0ec0d542e8 core/cli: keep database url :memory:; default url None
Unset/empty url still maps to durable user-data chat.db.
Explicit :memory: is not rewritten; CLI warns when not quiet.
DatabaseConfig.url default is None; engine treats None as memory.
2026-07-19 21:38:21 +08:00
NCBM 182546d60e core/tools: narrow list[object] for batch command argv typing
basedpyright treated bare list elements as Unknown after isinstance(list);
cast to list[object] so prek basedpyright exits clean.
2026-07-19 20:39:16 +08:00
NCBM e1e4df0f3f core/agent: strengthen todo prompts when stack not empty
Inject turn-start [TODO REMINDER] for non-empty stacks; rewrite end-of-turn
[TODO OPEN] copy so open items read as unfinished work. needs_review now
always true while open items remain (even if todo_* ran this turn); terminal-only
stacks still need review if untouched. Update tool docs and tests.
2026-07-19 20:34:01 +08:00
NCBM a54fa534f3 test/tools: clean shell confirm format test 2026-07-19 13:05:43 +08:00
NCBM fa9f28687f test/tools: fix shell confirm format test syntax 2026-07-19 13:02:24 +08:00
NCBM 95f277eda7 core/tools: shell confirm argv uses $(command); full -c body indented 2026-07-19 13:01:06 +08:00
NCBM f13ea9f316 core/tools: soft-confirm file overwrite only, not partial edits 2026-07-19 03:05:01 +08:00
NCBM f51d11d46f core/tools/process: add run_command_batch with pipe_out and mix_stderr 2026-07-19 02:46:21 +08:00
NCBM 5b51de12e0 core/cli: skip blocking GET /models on chat ready
Only fetch remote catalog when interactive model pick needs it; otherwise
use config/--model/session and warm Tab cache in the background. Cap list
fetch at 5s so a dead API cannot hang startup.
2026-07-19 02:08:58 +08:00
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
71 changed files with 5368 additions and 533 deletions
+48 -6
View File
@@ -1,11 +1,20 @@
# Single pipeline: CI on main/PRs; on v* tags, publish only after CI succeeds.
# Retagging the same commit (delete + push tag) re-triggers this workflow.
name: CI
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
# Cancel superseded runs on the same ref (e.g. force-push / retag races).
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
@@ -18,15 +27,14 @@ jobs:
python-version: "3.14"
- name: Install PDM
uses: pdm-project/setup-pdm@v4
with:
python-version: "3.14"
run: pip install pdm
- name: Install dependencies
run: pdm sync --dev
# Locked install so CI matches prek pins / pdm.lock.
run: pdm install --check -G :all
- name: Ruff check
run: pdm run ruff check --output-format=github .
run: pdm run ruff check .
- name: Ruff format
run: pdm run ruff format --check .
@@ -34,5 +42,39 @@ jobs:
- name: basedpyright
run: pdm run basedpyright .
- name: pytest
- name: Pytest
run: pdm run pytest
# Only on version tags, and only after check is green (no publish on red CI).
publish:
needs: check
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Install PDM
run: pip install pdm
- name: Build package
run: pdm build
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: dist/*
generate_release_notes: true
fail_on_unmatched_files: true
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
-39
View File
@@ -1,39 +0,0 @@
name: Release and Publish Python Package
on:
push:
tags:
- "v*"
permissions:
contents: write
id-token: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Install build
run: python -m pip install --upgrade pip build
- name: Build package
run: python -m build --sdist --wheel --outdir dist/ .
- name: Publish package
uses: pypa/gh-action-pypi-publish@release/v1
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
files: |
dist/*
generate_release_notes: true
+16 -10
View File
@@ -17,9 +17,14 @@ pdm run ruff format . # apply formatting
pdm run ruff format --check . # CI: fail if unformatted (do not skip)
pdm run basedpyright . # type checking (basedpyright, "recommended" strictness)
pdm run pytest # tests (pytest-asyncio auto mode)
# Commit gateway (prek — https://prek.j178.dev/): ruff check/format + basedpyright
uv tool install prek # once
prek install # wire git pre-commit hook (once per clone)
prek run --all-files # run all hooks on demand
```
GitHub Actions runs `ruff check`, `ruff format --check`, `basedpyright`, then `pytest`. Format locally with `pdm run ruff format .` so CI does not fail after publish.
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
@@ -73,15 +78,16 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
Module-level `@tool` handlers. Call `set_workspace_root()` before use.
- **`workspace`**: path resolve under root; path substring denylist; command basename denylist; clearer deny messages.
- **`file`**: `read_file`, `write_file`, `listdir`, `tree`, `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace`, `edit_lineno` (1-based range), `copy_path` / `move_path` / `delete_path`.
- **`process`**: `run_command` (argv, no shell, timeout, optional stdin/env); PTY `open_pty` / `read_pty` / `write_pty` / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)`; session limit/idle TTL/output budget; close terminate→kill.
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots; path substring denylist; command basename denylist (CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); clearer deny messages.
- **`file`**: `read_file` (`with_lineno`), `write_file`, `listdir`, `tree` (VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `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); `run_command_batch` (serial steps, `pipe_out` on provider, `mix_stderr`, `stop_on_error`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (escapes) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`\xHH`, `ctrl+x`, `key=esc`); secrets via `ask_into_pty` (not `write_pty` data); session limit/idle TTL/output budget; close terminate→kill.
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget.
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn).
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for shells/REPLs and `python|bash -c` one-liners (argv + code preview); deny may include a user comment for the model. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn).
- **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems.
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core).
- **`DEFAULT_TOOLS`**: file + process + vcs + chat tool list for a `ToolRegistry`.
- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by id; DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work (`[TODO OPEN WORK]`); all-terminal non-empty = hygiene (`[TODO HYGIENE]`); nag channel via `[agent] todo_nag_strategy` (`developer`/`user` = prose nag; `synthetic_tool` = forged `todo_list` call + **real** `stack.render()` result + ToolCall/Result events; `none`).
- **`DEFAULT_TOOLS`**: file + process + vcs + chat + todo tool list for a `ToolRegistry`.
### Prompting (`prompting.py`)
@@ -91,11 +97,11 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data if unset/`:memory:`); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--stream/--no-stream`, `--quiet`. Root `--log-level`.
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""``"""`; `/edit` via `$EDITOR`. `/yolo on|off|once` for soft destructive confirms.
- **`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 when url unset; explicit `url = ":memory:"` kept + warn); 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 `$VISUAL`/`$EDITOR` (blocking only). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack.
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**.
- Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then `/retry`.
- **`plyngent providers`**, **`config path|edit`**. No providers + `$EDITOR` → optional edit then reload.
- **`plyngent providers`**, **`config path|edit`**. Config open: `$VISUAL`/`$EDITOR` (wait), else system default (`xdg-open` / `open` / `startfile`, non-blocking). `/edit` stays blocking-only. No providers → optional edit then reload when waited.
- Tools default on; workspace defaults to cwd; `--max-rounds` default 32. Readline history under platformdirs (`repl_history`). PTY: `close_all` on chat exit.
### Composition utility: `Forward` descriptor
+26 -10
View File
@@ -71,14 +71,22 @@ pdm run basedpyright .
pdm run pytest
```
CI fails if sources are not Ruff-formatted (`ruff format --check`). Run `pdm run ruff format .` before push.
**Commit gateway** ([prek](https://prek.j178.dev/)): runs ruff check + format and basedpyright on `git commit` so format is not forgotten.
```bash
uv tool install prek # once
prek install # once per clone (installs .git/hooks/pre-commit)
prek run --all-files # run all hooks on demand
```
Config: `prek.toml`. CI still runs the same checks in GitHub Actions.
## Basic usage
```bash
# 1) Create / open config
plyngent config path
plyngent config edit # needs $EDITOR
plyngent config edit # $VISUAL/$EDITOR, else system open (xdg-open/open/startfile)
# Minimal provider (OpenAI platform — Responses API; preset defaults to openai):
# [providers.oai]
@@ -103,7 +111,7 @@ Default config path (platformdirs):
```bash
plyngent config path
plyngent config edit # opens $EDITOR (e.g. codium --wait)
plyngent config edit # $VISUAL/$EDITOR (e.g. codium --wait), else system default
```
Copy the example and fill in a real token:
@@ -132,7 +140,7 @@ max_context_tokens = 200000
Supported provider presets today: `openai` (default if `preset` is omitted; default models `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-nano` when `models` is omitted), `openai-compatible`, `deepseek` (OpenAI convention; default models `deepseek-v4-flash` and `deepseek-v4-pro` if `models` is omitted). Anthropic presets are modeled in config but not wired in the runtime client yet.
If `[database]` is omitted (or SQLite `url` is empty/`":memory:"`), chat uses a durable file under the user data dir (e.g. `~/.local/share/plyngent/chat.db` on Linux).
If `[database]` is omitted (or SQLite `url` is unset/empty), chat uses a durable file under the user data dir (e.g. `~/.local/share/plyngent/chat.db` on Linux). Set `url = ":memory:"` for a true in-memory SQLite (CLI warns; no file; useful for tests).
## Chat
@@ -178,7 +186,7 @@ Exit codes (one-shot):
### Input ergonomics
- **Multiline**: start a message with `"""`, end a later line with `"""`.
- **`/edit`**: compose a turn in `$EDITOR` (empty buffer cancels).
- **`/edit`**: compose a turn in `$VISUAL`/`$EDITOR` (blocking only; empty cancels).
- **Tab**: completes slash commands and some arguments (provider, model, on/off, export, `/help` targets).
### Slash commands
@@ -188,7 +196,8 @@ Type `/help` in the REPL for the live list. Common ones:
| Command | Purpose |
|---------|---------|
| `/status` | Provider, session, context/usage estimates |
| `/history [n]` | Recent messages |
| `/history [n\|last]` | Recent messages (default preview; `last`/`1` = full + markdown) |
| `/history --full` | Full bodies for the selected window |
| `/sessions` | Sessions for this workspace |
| `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) |
| `/export [md\|json] [path]` | Transcript from DB (no secrets) |
@@ -198,8 +207,11 @@ Type `/help` in the REPL for the live list. Common ones:
| `/retry` | Re-run incomplete last user turn (after error/cancel) |
| `/provider` `/model` | Switch without restarting |
| `/models` | List config + remote `GET /models` (`--refresh` bypasses cache) |
| `/config` | Edit `plyngent.toml` in `$EDITOR` and reload |
| `/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` ($VISUAL/$EDITOR or system open); reload after blocking editor |
| `/quit` | Leave the REPL |
User messages are saved immediately. On API error or Ctrl+C, partial assistant/tool output is discarded but the user message stays so `/retry` works after resume. Interactive auto-retry uses 10s / 20s / 30s delays.
@@ -212,14 +224,18 @@ User messages are saved immediately. On API error or Ctrl+C, partial assistant/t
## Tools (when enabled)
Default registry: file ops, `run_command` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), and human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`).
Default registry: file ops (including `tree` with default noise-dir skips), `run_command` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
Safety defaults:
- Paths stay under the workspace; optional `path_denylist` substrings.
- Paths stay under the workspace; optional `path_denylist` substrings (`tree` also skips denylisted children by default).
- Command basename denylist (e.g. dangerous shells/utilities).
- Destructive tools (delete/move/overwrite) can require confirm (`confirm_destructive`; default deny in non-TTY). Override for the session with `/yolo on|off|once` or startup `--yes` (path/command denylists still apply).
- PTY sessions: caps, idle TTL, output budget; master FD is non-inheritable; sessions closed on chat exit.
Prefer file tools over full-screen editors (`vim`/`nano`) for edits. `read_pty` sanitizes CSI/controls
so tool results cannot reprogram the host TTY (no host terminal reset on exit).
`write_pty` is literal text only; use `write_pty_keys` for `\xHH`, `ctrl+x`, `key=esc|enter|…`.
For passwords/sudo/ssh prompts use `ask_into_pty` (human types locally; answer never returns to the model).
## Usage / context (CLI)
+8 -2
View File
@@ -2,11 +2,13 @@
# Copy to the path shown by: plyngent config path
# Then set access_key_or_token and adjust models.
# Optional. If omitted (or SQLite url is empty / ":memory:"), the CLI uses a
# durable file under the platform user data dir (e.g. ~/.local/share/plyngent/chat.db).
# Optional. If omitted (or SQLite url unset/empty), the CLI uses a durable file under
# the platform user data dir (e.g. ~/.local/share/plyngent/chat.db).
# url = ":memory:" → true in-memory SQLite (CLI warns; useful for tests).
# [database]
# implementation = "sqlite"
# url = "/path/to/chat.db"
# # url = ":memory:"
[agent]
system_prompt = "You are a careful coding assistant. Prefer small, verified edits."
@@ -16,6 +18,10 @@ confirm_destructive = true
path_denylist = ["/secrets/", ".ssh/", ".gnupg/"]
# Soft context budget in estimated tokens (~4 chars/token when API usage missing).
max_context_tokens = 200000
# How open-todo nags are injected into model context:
# developer (default) | user | synthetic_tool | none
# (legacy system is treated as developer)
# todo_nag_strategy = "developer"
# Optional: custom compact/summarisation prompts (empty = built-in defaults).
# compact_system_prompt = ""
Generated
+29 -29
View File
@@ -5,7 +5,7 @@
groups = ["default", "dev"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
content_hash = "sha256:b849e1647ce0ec47b61889ae0e66863acce020cd1f2e3deaeb9e2e92becf9da5"
content_hash = "sha256:94dc16bf6ed12e2fcd7a109b500e9b2d7e39f224b60472d64ecb1d3436ddbb82"
[[metadata.targets]]
requires_python = "~=3.14"
@@ -326,13 +326,13 @@ files = [
[[package]]
name = "platformdirs"
version = "4.10.0"
version = "4.10.1"
requires_python = ">=3.10"
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
groups = ["default"]
files = [
{file = "platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a"},
{file = "platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7"},
{file = "platformdirs-4.10.1-py3-none-any.whl", hash = "sha256:0e4eff26be2d75293977f7cddc153fd9b8eaa7fb0c7b64ffe4076cb443117443"},
{file = "platformdirs-4.10.1.tar.gz", hash = "sha256:ceab4084426fe6319ce18e86deada8ab1b7487c7aee7040c55e277c9ae793695"},
]
[[package]]
@@ -470,29 +470,29 @@ files = [
[[package]]
name = "ruff"
version = "0.15.21"
version = "0.15.22"
requires_python = ">=3.7"
summary = "An extremely fast Python linter and code formatter, written in Rust."
groups = ["dev"]
files = [
{file = "ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d"},
{file = "ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd"},
{file = "ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646"},
{file = "ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be"},
{file = "ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd"},
{file = "ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41"},
{file = "ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86"},
{file = "ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67"},
{file = "ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8"},
{file = "ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075"},
{file = "ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341"},
{file = "ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d"},
{file = "ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233"},
{file = "ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f"},
{file = "ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd"},
{file = "ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3"},
{file = "ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8"},
{file = "ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500"},
{file = "ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8"},
{file = "ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697"},
{file = "ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f"},
{file = "ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576"},
{file = "ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178"},
{file = "ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224"},
{file = "ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a"},
{file = "ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e"},
{file = "ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb"},
{file = "ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74"},
{file = "ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c"},
{file = "ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296"},
{file = "ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262"},
{file = "ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64"},
{file = "ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf"},
{file = "ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde"},
{file = "ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661"},
{file = "ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809"},
]
[[package]]
@@ -527,13 +527,13 @@ files = [
[[package]]
name = "tomlkit"
version = "0.15.0"
version = "0.15.1"
requires_python = ">=3.9"
summary = "Style preserving TOML library"
groups = ["default"]
files = [
{file = "tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738"},
{file = "tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3"},
{file = "tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304"},
{file = "tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97"},
]
[[package]]
@@ -549,7 +549,7 @@ files = [
[[package]]
name = "urllib3-future"
version = "2.22.901"
version = "2.23.900"
requires_python = ">=3.7"
summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces"
groups = ["default"]
@@ -559,8 +559,8 @@ dependencies = [
"qh3<2.0.0,>=1.5.4; (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\"",
]
files = [
{file = "urllib3_future-2.22.901-py3-none-any.whl", hash = "sha256:dc2d4572ba612be69e3f6db87d1f469b7d08f107e80fd8749081d7e6a5a2fe10"},
{file = "urllib3_future-2.22.901.tar.gz", hash = "sha256:fbece0ff51299a213c926897ffbed61d820ab5ef2ba81cece361c049fe2f51a9"},
{file = "urllib3_future-2.23.900-py3-none-any.whl", hash = "sha256:4303dc9a5d8c4c810f3fcd3b13bd62d69c3a4f63bb7e62089410907c34aeaf6b"},
{file = "urllib3_future-2.23.900.tar.gz", hash = "sha256:f488f22983f96cd8c733640b47e6a32def462d33c59bcc4bea6d97a8568b70c8"},
]
[[package]]
+48
View File
@@ -0,0 +1,48 @@
# 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
#
# Pin additional_dependencies to the same versions as pdm.lock (dev group).
# Bump these when you pdm update ruff / basedpyright.
default_install_hook_types = ["pre-commit"]
default_stages = ["pre-commit"]
default_language_version = { python = "python3.14" }
# Match CI / local pdm env (see pdm.lock).
repos = [
{
repo = "local",
hooks = [
{
id = "ruff-check",
name = "ruff check",
entry = "ruff check",
language = "python",
types_or = ["python", "pyi"],
args = [],
require_serial = true,
additional_dependencies = ["ruff==0.15.22"],
},
{
id = "ruff-format",
name = "ruff format",
entry = "ruff format --check --force-exclude",
language = "python",
types_or = ["python", "pyi"],
require_serial = true,
additional_dependencies = ["ruff==0.15.22"],
},
{
id = "basedpyright",
name = "basedpyright",
entry = "basedpyright",
language = "python",
pass_filenames = false,
require_serial = true,
additional_dependencies = ["basedpyright==1.39.9"],
},
],
},
]
+3 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "plyngent"
version = "0.1.1"
version = "0.1.3"
description = "LLM chat and agent toolkit"
authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" },
@@ -27,8 +27,8 @@ plyngent = "plyngent.cli:main"
[dependency-groups]
dev = [
"basedpyright>=1.39.7",
"ruff>=0.15.17",
"basedpyright==1.39.9",
"ruff==0.15.22",
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
+56 -3
View File
@@ -19,6 +19,11 @@ from .budget import (
)
from .events import UsageEvent
from .loop import DEFAULT_MAX_ROUNDS, run_chat_loop
from .todo_nag import (
DEFAULT_TODO_NAG_STRATEGY,
inject_todo_nag_for_stack_with_events,
parse_todo_nag_strategy,
)
from .usage import TokenUsage
if TYPE_CHECKING:
@@ -29,6 +34,8 @@ if TYPE_CHECKING:
from .client import ChatClient
from .events import AgentEvent
from .todo_nag import TodoNagStrategy
from .todo_stack import TodoStack
from .tools import ToolRegistry
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
@@ -118,6 +125,8 @@ class ChatAgent:
max_tool_result_chars: int
parallel_tools: bool
max_context_tokens: int
todo_stack: TodoStack | None
todo_nag_strategy: TodoNagStrategy
messages: list[AnyChatMessage]
session_usage: TokenUsage
last_turn_usage: TokenUsage
@@ -143,6 +152,8 @@ class ChatAgent:
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
parallel_tools: bool = True,
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
todo_stack: TodoStack | None = None,
todo_nag_strategy: str | TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
) -> None:
self.client = client
self.model = model
@@ -157,6 +168,8 @@ class ChatAgent:
self.max_tool_result_chars = max_tool_result_chars
self.parallel_tools = parallel_tools
self.max_context_tokens = max_context_tokens
self.todo_stack = todo_stack
self.todo_nag_strategy = parse_todo_nag_strategy(str(todo_nag_strategy))
self.messages = list(messages) if messages is not None else []
self.session_usage = TokenUsage()
self.last_turn_usage = TokenUsage()
@@ -200,14 +213,38 @@ class ChatAgent:
if self._persist_from == 0:
self._persist_from = 1
def replace_messages(
self,
messages: Sequence[AnyChatMessage],
*,
persisted: bool = True,
persist_from: int | None = None,
) -> None:
"""Replace in-memory history and align the persistence checkpoint.
When *persist_from* is set, it is used as the checkpoint cursor
(clamped to ``[0, len(messages)]``). Otherwise *persisted* true means
all messages are already stored; false means none are.
"""
self.messages = list(messages)
if persist_from is not None:
self._persist_from = max(0, min(persist_from, len(self.messages)))
else:
self._persist_from = len(self.messages) if persisted else 0
self._ensure_system_prompt()
@property
def persist_from(self) -> int:
"""Index of the first unpersisted message (checkpoint cursor)."""
return self._persist_from
async def load_history(self) -> None:
"""Replace in-memory messages from the bound memory session."""
if self.memory is None or self.session_id is None:
msg = "load_history requires memory and session_id"
raise RuntimeError(msg)
self.messages = await self.memory.list_messages(self.session_id)
self._persist_from = len(self.messages)
self._ensure_system_prompt()
loaded = await self.memory.list_messages(self.session_id)
self.replace_messages(loaded, persisted=True)
async def bind_session(self, session_id: int, *, load: bool = True) -> None:
"""Attach a memory session id; optionally load existing messages."""
@@ -265,12 +302,26 @@ class ChatAgent:
side-effecting tools. Unfinished assistant/stream suffix is rolled back.
"""
user_index = self._user_index(user_msg)
if self.todo_stack is not None:
self.todo_stack.begin_turn()
completed = False
turn_usage = TokenUsage()
turn_rounds = 0
last_request = TokenUsage()
try:
# Turn-start nag before first completion; yield events so CLI flushes
# (synthetic_tool → ToolCall/Result chrome, not glued to text).
if self.todo_stack is not None and not self.todo_stack.is_empty():
_injected, nag_events = inject_todo_nag_for_stack_with_events(
self.messages,
self.todo_stack,
kind="turn_start",
strategy=self.todo_nag_strategy,
)
for nag_event in nag_events:
yield nag_event
async for event in run_chat_loop(
self.client,
self.messages,
@@ -283,6 +334,8 @@ class ChatAgent:
max_tool_result_chars=self.max_tool_result_chars,
parallel_tools=self.parallel_tools,
max_context_tokens=self.max_context_tokens,
todo_stack=self.todo_stack,
todo_nag_strategy=self.todo_nag_strategy,
):
if isinstance(event, UsageEvent):
turn_rounds += 1
+5 -4
View File
@@ -8,8 +8,8 @@ from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
AssistantFunctionToolCall,
ChatCompletionsParam,
DeveloperChatMessage,
SystemChatMessage,
ToolChatMessage,
UserChatMessage,
)
@@ -49,6 +49,8 @@ def format_transcript(messages: Sequence[AnyChatMessage]) -> str:
for msg in messages:
if isinstance(msg, SystemChatMessage):
lines.append(f"[system] {msg.content}")
elif isinstance(msg, DeveloperChatMessage):
lines.append(f"[developer] {msg.content}")
elif isinstance(msg, UserChatMessage):
lines.append(f"[user] {msg.content}")
elif isinstance(msg, AssistantChatMessage):
@@ -62,10 +64,9 @@ def format_transcript(messages: Sequence[AnyChatMessage]) -> str:
lines.append(f"[assistant tool_call] {call.function.name}({call.function.arguments})")
else:
lines.append(f"[assistant tool_call] custom id={call.id}")
elif isinstance(msg, ToolChatMessage):
lines.append(f"[tool {msg.tool_call_id}] {msg.content}")
else:
lines.append(f"[message] {msg!r}")
# ToolChatMessage (remaining AnyChatMessage arm)
lines.append(f"[tool {msg.tool_call_id}] {msg.content}")
return "\n".join(lines)
+107 -2
View File
@@ -37,6 +37,7 @@ from .events import (
ToolResultEvent,
UsageEvent,
)
from .todo_nag import DEFAULT_TODO_NAG_STRATEGY, inject_todo_nag_for_stack_with_events
from .usage import resolve_round_usage, token_usage_from_api
if TYPE_CHECKING:
@@ -45,6 +46,8 @@ if TYPE_CHECKING:
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
from .client import ChatClient
from .todo_nag import TodoNagStrategy
from .todo_stack import TodoStack
from .tools import ToolRegistry
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
@@ -114,6 +117,66 @@ async def _execute_tool_calls(
yield ToolResultEvent(message=tool_msg)
def _assistant_has_payload(assistant: AssistantChatMessage) -> bool:
"""True if the model produced text, reasoning, or tool calls."""
if assistant.tool_calls is not UNSET and assistant.tool_calls:
return True
if isinstance(assistant.content, str) and assistant.content.strip():
return True
reasoning = assistant.reasoning_content
return bool(isinstance(reasoning, str) and reasoning.strip())
def _finish_reason_value(finish: object) -> str | None:
if finish is UNSET or finish is None:
return None
if isinstance(finish, str) and finish.strip():
return finish.strip()
return None
def _validate_assistant_terminal(
assistant: AssistantChatMessage,
*,
finish_reason: str | None,
stream_terminal: bool,
) -> None:
"""Raise when the round is not a usable agent stop.
Distinguishes empty generation, truncated/filtered stops, and missing
stream terminals (network/client glitch) from a normal stop/tool_calls end.
"""
reason = (finish_reason or "").lower() or None
if reason in {"length", "content_filter"}:
label = "truncated (max tokens)" if reason == "length" else "content filter"
detail = ""
if isinstance(assistant.content, str) and assistant.content.strip():
detail = f"; partial text kept ({len(assistant.content)} chars)"
msg = f"model stopped early: {label}{detail}"
raise RuntimeError(msg)
if reason in {"failed", "incomplete", "cancelled"}:
msg = f"model response status: {reason}"
raise RuntimeError(msg)
if _assistant_has_payload(assistant):
return
if not stream_terminal and reason is None:
msg = (
"stream ended without a terminal signal (no finish_reason / response.completed) and empty assistant output"
)
raise RuntimeError(msg)
if reason in {"stop", "tool_calls", "function_call"} or reason is None:
msg = "empty model completion (no text, reasoning, or tool calls)"
raise RuntimeError(msg)
msg = f"empty model completion (finish_reason={reason})"
raise RuntimeError(msg)
async def _non_stream_round(
client: ChatClient,
param: ChatCompletionsParam,
@@ -122,7 +185,14 @@ async def _non_stream_round(
if not response.choices:
msg = "chat completion response contained no choices"
raise RuntimeError(msg)
assistant = response.choices[0].message
choice = response.choices[0]
assistant = choice.message
finish = _finish_reason_value(choice.finish_reason)
_validate_assistant_terminal(
assistant,
finish_reason=finish,
stream_terminal=True,
)
reasoning = assistant.reasoning_content
if isinstance(reasoning, str) and reasoning:
yield ReasoningDeltaEvent(content=reasoning)
@@ -154,6 +224,8 @@ async def _stream_round(
reasoning_parts: list[str] = []
tool_deltas: list[StreamToolCallDelta] = []
last_api_usage: object = UNSET
finish_reason: str | None = None
saw_terminal = False
async for chunk in stream:
parsed = token_usage_from_api(chunk.usage)
@@ -162,6 +234,10 @@ async def _stream_round(
if not chunk.choices:
continue
choice = chunk.choices[0]
fr = _finish_reason_value(choice.finish_reason)
if fr is not None:
finish_reason = fr
saw_terminal = True
delta = choice.delta
if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
reasoning_parts.append(delta.reasoning_content)
@@ -185,6 +261,16 @@ async def _stream_round(
tool_calls=tool_calls,
reasoning_content=full_reasoning or UNSET,
)
# Usage-only final chunks leave choices empty; a non-empty usage after
# content still is not a finish_reason. Prefer explicit finish_reason.
# If we got payload but no finish_reason, treat as terminal (some providers
# omit it); if empty and no finish_reason, flag as missing terminal.
stream_terminal = saw_terminal or _assistant_has_payload(assistant)
_validate_assistant_terminal(
assistant,
finish_reason=finish_reason,
stream_terminal=stream_terminal,
)
yield AssistantMessageEvent(message=assistant)
yield UsageEvent(
usage=resolve_round_usage(last_api_usage, param.messages, assistant),
@@ -222,7 +308,7 @@ def _last_assistant(messages: list[AnyChatMessage], pre_len: int) -> AssistantCh
return last
async def run_chat_loop(
async def run_chat_loop( # noqa: C901 — multi-phase tool loop
client: ChatClient,
messages: list[AnyChatMessage],
*,
@@ -235,6 +321,8 @@ async def run_chat_loop(
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
parallel_tools: bool = True,
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
todo_stack: TodoStack | None = None,
todo_nag_strategy: TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
) -> AsyncIterator[AgentEvent]:
"""Multi-round chat/tool loop; mutates ``messages`` in place and yields events.
@@ -242,6 +330,10 @@ async def run_chat_loop(
text deltas as chunks arrive; tool calls are merged from stream deltas.
Multiple tool calls in one round run in parallel when ``parallel_tools``.
Request payloads may shrink older tool results when over ``max_context_tokens``.
When *todo_stack* is set and still needs review after a natural stop
(open items, or non-empty stack untouched this turn), injects a review nag
(channel from *todo_nag_strategy*) once so the model reconciles unfinished work.
"""
tool_items: Sequence[AnyToolItem] | None = None
if tools is not None and len(tools) > 0:
@@ -252,6 +344,7 @@ async def run_chat_loop(
# Calibrate soft-compact from last model call's prompt_tokens (API preferred).
prompt_tokens_hint: int | None = None
sent_estimate_tokens: int | None = None
todo_review_injected = False
while True:
while rounds_used < allowance:
@@ -280,6 +373,18 @@ async def run_chat_loop(
assistant = _last_assistant(messages, pre_len)
tool_calls = assistant.tool_calls
if tool_calls is UNSET or not tool_calls or tools is None:
if todo_stack is not None and todo_stack.needs_review() and not todo_review_injected:
todo_review_injected = True
injected, nag_events = inject_todo_nag_for_stack_with_events(
messages,
todo_stack,
kind="end_of_turn",
strategy=todo_nag_strategy,
)
for nag_event in nag_events:
yield nag_event
if injected:
continue
return
async for event in _execute_tool_calls(
tools,
+53 -7
View File
@@ -29,10 +29,10 @@ from plyngent.lmproto.openai_compatible.model import (
ChatCompletionsParam,
ChunkChoice,
DeltaMessage,
DeveloperChatMessage,
StreamFunctionDelta,
StreamToolCallDelta,
SystemChatMessage,
ToolChatMessage,
ToolFunctionItem,
UserChatMessage,
)
@@ -97,21 +97,22 @@ def chat_messages_to_responses_input(
if isinstance(message, SystemChatMessage):
if message.content.strip():
instructions_parts.append(message.content)
elif isinstance(message, DeveloperChatMessage):
# Keep mid-turn control as input developer messages (not folded into instructions).
if message.content.strip():
items.append(ResponseEasyInputMessage(role="developer", content=message.content))
elif isinstance(message, UserChatMessage):
items.append(ResponseEasyInputMessage(role="user", content=message.content))
elif isinstance(message, AssistantChatMessage):
items.extend(_assistant_to_input_items(message))
elif isinstance(message, ToolChatMessage):
else:
# ToolChatMessage (remaining AnyChatMessage arm)
items.append(
ResponseFunctionToolCallOutput(
call_id=message.tool_call_id,
output=message.content,
)
)
else:
content = getattr(message, "content", None)
if isinstance(content, str) and content:
items.append(ResponseEasyInputMessage(role="user", content=content))
instructions = "\n\n".join(instructions_parts) if instructions_parts else None
return instructions, items
@@ -158,10 +159,33 @@ def _reasoning_summary_text(response: Response) -> str:
return "".join(parts)
def responses_status_to_finish_reason(
response: Response,
*,
has_tool_calls: bool,
) -> str:
"""Map Responses ``status`` to a chat-style finish_reason for the agent loop."""
status = response.status
status_s = status if isinstance(status, str) else None
if status_s == "incomplete":
details = response.incomplete_details
if details is not UNSET and details is not None:
raw_reason = details.reason
if raw_reason is not UNSET and raw_reason == "content_filter":
return "content_filter"
return "length"
if status_s in {"failed", "cancelled"}:
return status_s
if has_tool_calls:
return "tool_calls"
return "stop"
def response_to_chat_completion(response: Response) -> ChatCompletionResponse:
"""Wrap Responses result as a synthetic chat completion for the agent loop."""
assistant = response_to_assistant_message(response)
finish: str | None = "tool_calls" if assistant.tool_calls is not UNSET else "stop"
has_tools = assistant.tool_calls is not UNSET and bool(assistant.tool_calls)
finish = responses_status_to_finish_reason(response, has_tool_calls=has_tools)
usage = response.usage if response.usage is not UNSET else UNSET
created = int(response.created_at)
return ChatCompletionResponse(
@@ -180,6 +204,28 @@ def response_to_chat_completion(response: Response) -> ChatCompletionResponse:
)
def finish_reason_chunk(
*,
model: str,
finish_reason: str,
created: int = 0,
) -> ChatCompletionChunk:
"""Terminal stream chunk carrying only ``finish_reason`` (no delta text)."""
return ChatCompletionChunk(
id="resp-stream",
object="chat.completion.chunk",
created=created,
model=model,
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(),
finish_reason=cast("Any", finish_reason),
)
],
)
def text_delta_chunk(*, model: str, content: str, created: int = 0) -> ChatCompletionChunk:
return ChatCompletionChunk(
id="resp-stream",
+17 -6
View File
@@ -8,8 +8,11 @@ from msgspec import UNSET
from plyngent.agent.responses_bridge import (
chat_param_to_responses_kwargs,
finish_reason_chunk,
reasoning_delta_chunk,
response_to_assistant_message,
response_to_chat_completion,
responses_status_to_finish_reason,
text_delta_chunk,
tool_call_chunks_from_response,
usage_chunk_from_response,
@@ -109,12 +112,20 @@ class ResponsesChatClient:
except TypeError, ValueError, msgspec.ValidationError:
final = None
if final is not None:
for chunk in tool_call_chunks_from_response(final, model=model):
yield chunk
usage = usage_chunk_from_response(final, model=model)
if usage is not None:
yield usage
if final is None:
# Stream ended without response.completed — signal missing terminal.
# Loop will treat empty + no finish_reason as a glitch.
return
assistant = response_to_assistant_message(final)
has_tools = assistant.tool_calls is not UNSET and bool(assistant.tool_calls)
finish = responses_status_to_finish_reason(final, has_tool_calls=has_tools)
for chunk in tool_call_chunks_from_response(final, model=model):
yield chunk
yield finish_reason_chunk(model=model, finish_reason=finish)
usage = usage_chunk_from_response(final, model=model)
if usage is not None:
yield usage
def wrap_openai_for_agent(
+183
View File
@@ -0,0 +1,183 @@
"""Inject todo stack nags into the message list (configurable channel)."""
from __future__ import annotations
import uuid
from typing import TYPE_CHECKING, Literal, cast
from msgspec import UNSET
from plyngent.lmproto.openai_compatible.model import (
AnyAssistantToolCall,
AssistantChatMessage,
AssistantFunctionTool,
AssistantFunctionToolCall,
DeveloperChatMessage,
ToolChatMessage,
UserChatMessage,
)
if TYPE_CHECKING:
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
from .events import AgentEvent
from .todo_stack import TodoStack
type TodoNagStrategy = Literal["developer", "user", "synthetic_tool", "none"]
type TodoNagKind = Literal["turn_start", "end_of_turn"]
TODO_NAG_STRATEGIES: frozenset[str] = frozenset({"developer", "user", "synthetic_tool", "none"})
DEFAULT_TODO_NAG_STRATEGY: TodoNagStrategy = "developer"
_SYNTHETIC_TOOL_NAME = "todo_list"
def parse_todo_nag_strategy(raw: str | None) -> TodoNagStrategy:
"""Normalize config/CLI text to a strategy; unknown → developer.
Legacy ``system`` is accepted as ``developer`` (mid-turn system was folded
into Responses ``instructions`` and was not a useful distinct channel).
"""
token = (raw or DEFAULT_TODO_NAG_STRATEGY).strip().lower().replace("-", "_")
if token == "system":
return "developer"
if token not in TODO_NAG_STRATEGIES:
return DEFAULT_TODO_NAG_STRATEGY
return cast("TodoNagStrategy", token)
def nag_body(stack: TodoStack, kind: TodoNagKind) -> str:
"""Prose OPEN WORK / HYGIENE prompt (developer / user strategies)."""
if kind == "turn_start":
return stack.turn_reminder_prompt()
return stack.review_prompt()
def synthetic_todo_list_result(stack: TodoStack) -> str:
"""Body for synthetic_tool: same text as a real ``todo_list`` tool result.
No OPEN WORK lecture — just the stack dump the model would get from
``todo_list``. The call remains forged; the payload is authentic render.
"""
return stack.render()
def _append_synthetic_todo_list(messages: list[AnyChatMessage], body: str) -> str:
"""Append forged todo_list call + result. Returns the synthetic tool_call id."""
call_id = f"todo-nag-{uuid.uuid4().hex[:12]}"
messages.append(
AssistantChatMessage(
content=UNSET,
tool_calls=[
AssistantFunctionToolCall(
id=call_id,
function=AssistantFunctionTool(
name=_SYNTHETIC_TOOL_NAME,
arguments="{}",
),
)
],
)
)
messages.append(ToolChatMessage(tool_call_id=call_id, content=body))
return call_id
def inject_todo_nag(
messages: list[AnyChatMessage],
body: str,
*,
strategy: TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
) -> bool:
"""Append a todo nag using *strategy*. Returns True if anything was appended.
Strategies:
- ``developer`` (default): control-plane message (safe for retry/history).
- ``user``: looks like a human turn (can confuse retry — use with care).
- ``synthetic_tool``: forged ``todo_list`` call + result pair (no handler run).
- ``none``: no injection.
Prefer :func:`inject_todo_nag_with_events` when the CLI needs ToolCall/Result
events so the display buffer flushes and tool chrome is not glued to text.
"""
return inject_todo_nag_with_events(messages, body, strategy=strategy)[0]
def inject_todo_nag_with_events(
messages: list[AnyChatMessage],
body: str,
*,
strategy: TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
) -> tuple[bool, list[AgentEvent]]:
"""Like :func:`inject_todo_nag`, also return display events for synthetic_tool.
For ``synthetic_tool``, emits :class:`ToolCallEvent` then
:class:`ToolResultEvent` so streaming UIs flush the assistant buffer and
show tool chrome (result is real stack text; call was not model-authored).
"""
from .events import ToolCallEvent, ToolResultEvent
if strategy == "none" or not body.strip():
return False, []
if strategy == "developer":
messages.append(DeveloperChatMessage(content=body))
return True, []
if strategy == "user":
messages.append(UserChatMessage(content=body))
return True, []
# strategy == "synthetic_tool" (only remaining inject path)
call_id = _append_synthetic_todo_list(messages, body)
assistant = messages[-2]
tool_msg = messages[-1]
events: list[AgentEvent] = []
if isinstance(assistant, AssistantChatMessage):
tool_calls = assistant.tool_calls
if tool_calls is not UNSET and tool_calls:
for call in tool_calls:
if isinstance(call, AssistantFunctionToolCall) and call.id == call_id:
events.append(ToolCallEvent(tool_call=cast("AnyAssistantToolCall", call)))
break
if isinstance(tool_msg, ToolChatMessage):
events.append(ToolResultEvent(message=tool_msg))
return True, events
def body_for_strategy(
stack: TodoStack,
kind: TodoNagKind,
strategy: TodoNagStrategy,
) -> str:
"""Choose inject payload: prose nag vs real todo_list render."""
if strategy == "synthetic_tool":
return synthetic_todo_list_result(stack)
return nag_body(stack, kind)
def inject_todo_nag_for_stack(
messages: list[AnyChatMessage],
stack: TodoStack,
*,
kind: TodoNagKind,
strategy: TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
) -> bool:
"""Build body from *stack* and inject with *strategy*."""
return inject_todo_nag(
messages,
body_for_strategy(stack, kind, strategy),
strategy=strategy,
)
def inject_todo_nag_for_stack_with_events(
messages: list[AnyChatMessage],
stack: TodoStack,
*,
kind: TodoNagKind,
strategy: TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
) -> tuple[bool, list[AgentEvent]]:
"""Build body from *stack*; inject and return CLI-facing events."""
return inject_todo_nag_with_events(
messages,
body_for_strategy(stack, kind, strategy),
strategy=strategy,
)
+378
View File
@@ -0,0 +1,378 @@
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"})
_REVIEW_OPEN_TITLE_LIMIT = 8
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:
"""True when the stack still signals unfinished or unreconciled work.
Open (pending/in_progress) items always need attention. A non-empty stack
with only terminal items still needs a pop/clear if the agent ignored
todos this turn.
"""
if self.is_empty():
return False
if self.open_items():
return True
return 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 turn_reminder_prompt(self) -> str:
"""Mid-context nudge when a turn starts with a non-empty stack.
A non-empty stack usually means unfinished work (open items) or unfinished
stack hygiene (terminal items still grouped). Prefer finishing real work
over pure bookkeeping when both exist.
"""
n_open = len(self.open_items())
n_groups = self.depth
if n_open:
headline = (
f"[TODO OPEN WORK] Stack not empty: {n_open} open item(s) across "
f"{n_groups} group(s). This is unfinished work from earlier in the "
"session — not optional decoration."
)
action = (
"Treat open items as active commitments: continue them, mark "
"done/cancelled when finished, or push a child breakdown for the "
"current TOP item. Only todo_clear if the user abandoned the plan. "
"Do not ignore the stack while answering unrelated chatter."
)
else:
headline = (
f"[TODO HYGIENE] Stack not empty: {n_groups} group(s) with no open "
"items (all done/cancelled). Work may be finished; the stack is not."
)
action = (
"Pop finished TOP groups (or todo_clear when the whole plan is done). "
"Leaving only-terminal groups wastes context; clean up when you can."
)
lines = [
headline,
action,
"Tools: todo_list | todo_push(titles=[...]) | todo_update | todo_pop | todo_clear",
"Rules: TOP = current level; push = one sibling group; pop = whole TOP group.",
"Stack:",
self.render(),
]
return "\n".join(lines)
def review_prompt(self) -> str:
"""End-of-turn nag: non-empty stack still signals unfinished work."""
open_items = self.open_items()
n_open = len(open_items)
n_groups = self.depth
if n_open:
open_titles = "; ".join(f"{item.id}:{item.title}" for item in open_items[:_REVIEW_OPEN_TITLE_LIMIT])
if n_open > _REVIEW_OPEN_TITLE_LIMIT:
open_titles += f"; …(+{n_open - _REVIEW_OPEN_TITLE_LIMIT} more)"
headline = (
f"[TODO OPEN WORK] You stopped with {n_open} open item(s) still on "
f"the stack ({n_groups} group(s)). That usually means undone work."
)
action = (
"Do not end the turn while open tasks remain unaddressed. Next: "
"resume an open item, mark done/cancelled, pop a finished TOP group, "
"or push a child breakdown. Clear only if the user no longer wants "
f"the plan. Open: {open_titles}"
)
else:
headline = (
f"[TODO HYGIENE] Stack still has {n_groups} group(s) but every item "
"is done/cancelled. Real work looks finished; stack cleanup does not."
)
action = (
"Pop finished TOP groups (or todo_clear when the whole plan is done). "
"A non-empty all-terminal stack is unfinished task hygiene, not new work."
)
lines = [
headline,
action,
"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.",
"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
+15 -7
View File
@@ -13,9 +13,11 @@ from plyngent.typedef import JSONSchema # noqa: TC001
type ToolHandler = Callable[..., Any | Awaitable[Any]]
type DangerClassifier = Callable[[str, Mapping[str, object]], str | None]
# Returns True to allow, False to deny, or a non-empty str as denial reason for the model.
type ToolConfirmResult = bool | str
type ToolConfirmHook = Callable[
[str, Mapping[str, object], str],
bool | Awaitable[bool],
ToolConfirmResult | Awaitable[ToolConfirmResult],
]
_PRIMITIVE_SCHEMA: dict[type, JSONSchema] = {
@@ -212,7 +214,11 @@ class ToolRegistry:
return msgspec.json.encode(result).decode()
async def _maybe_confirm(self, name: str, args: dict[str, object]) -> str | None:
"""Return an error string if the user denies a dangerous tool, else None."""
"""If confirm is required and denied, return an error string for the model.
The confirm hook may return ``True`` (allow), ``False`` (deny), or a
non-empty string (deny with user comment for the model).
"""
if self._danger is None or self._on_confirm is None:
return None
reason = self._danger(name, args)
@@ -223,12 +229,14 @@ class ToolRegistry:
reason = self._danger(name, args)
if reason is None:
return None
allowed = self._on_confirm(name, args, reason)
if inspect.isawaitable(allowed):
allowed = await allowed
if allowed:
decision = self._on_confirm(name, args, reason)
if inspect.isawaitable(decision):
decision = await decision
if decision is True:
return None
return f"error: user denied tool {name!r} ({reason})"
if isinstance(decision, str) and decision.strip():
return f"error: tool {name!r} denied by user confirm ({reason}); user comment: {decision.strip()}"
return f"error: tool {name!r} denied by user confirm ({reason})"
async def execute(self, name: str, arguments_json: str) -> str:
"""Run a tool by name; returns a string result (errors become error text)."""
+47 -13
View File
@@ -82,12 +82,21 @@ def _warn_recoverable_providers(recoverable: Mapping[str, object]) -> None:
def _database_config(store: ConfigStore, *, quiet: bool = False) -> DatabaseConfig:
raw = dict(store.database)
# Prefer a durable file DB so sessions survive CLI restarts.
if raw.get("url") in {None, "", ":memory:"} and raw.get("implementation", "sqlite") == "sqlite":
url = raw.get("url")
impl = raw.get("implementation", "sqlite")
# Unset/empty → durable user-data chat.db so interactive sessions persist.
# Explicit ``:memory:`` is kept as-is (ephemeral; warn so it is not accidental).
if impl == "sqlite" and url in {None, ""}:
db_path = user_data_path("plyngent", ensure_exists=True) / _DEFAULT_DB_FILENAME
raw = {**raw, "implementation": "sqlite", "url": str(db_path)}
if not quiet:
click.secho(f"using database: {db_path}", fg="bright_black", err=True)
elif not quiet and impl == "sqlite" and url == ":memory:":
click.secho(
"warning: database url is :memory: — sessions are not persisted to disk",
fg="yellow",
err=True,
)
return msgspec.convert(raw, DatabaseConfig)
@@ -245,20 +254,26 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
preferred_model=preferred_model,
interactive=interactive,
)
# Build client early so we can always try GET /models for remote-first lists.
# Avoid blocking ready on GET /models unless interactive pick needs it.
from plyngent.cli.models_source import (
client_supports_models,
fetch_remote_model_ids,
model_choices_for_provider,
needs_remote_models_for_selection,
)
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
if needs_remote_models_for_selection(
provider,
preferred_model=preferred_model,
interactive=interactive,
):
client = create_client(provider)
try:
if client_supports_models(client):
remote_ids = await fetch_remote_model_ids(client)
except RuntimeError, TypeError, OSError, ValueError, TimeoutError:
remote_ids = None
choices = model_choices_for_provider(provider, remote_ids=remote_ids)
model_id = select_model(
provider,
@@ -282,9 +297,11 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
interactive_limits=interactive,
yolo=yolo,
)
# Seed remote model cache from startup fetch so Tab/complete stays warm.
# Seed cache if we already fetched; else warm in background for Tab.
if remote_ids is not None:
state.seed_remote_models(remote_ids)
elif not oneshot and interactive:
state.schedule_remote_models_warm()
if not quiet and not oneshot:
click.secho(f"workspace: {state.workspace}", fg="bright_black", err=True)
@@ -307,8 +324,14 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
finally:
await memory.close()
from plyngent.tools.process.pty_session import PtyManager
from plyngent.tools.temp_workspace import cleanup_temporary_workspaces
PtyManager.close_all()
from plyngent.tools.workspace import clear_policy_allowed_commands, set_policy_confirm_hook
set_policy_confirm_hook(None)
clear_policy_allowed_commands()
_ = cleanup_temporary_workspaces()
def _configure_logging(level: str) -> None:
@@ -506,10 +529,21 @@ def config_path_cmd(config_path: Path | None) -> None:
help="Path to plyngent.toml (default: platform config dir).",
)
def config_edit_cmd(config_path: Path | None) -> None:
"""Open the config file in $EDITOR (supports e.g. ``codium --wait``)."""
"""Open the config file in $VISUAL/$EDITOR, or system default if unset.
Blocking editors (e.g. ``codium --wait``) wait for exit. Without VISUAL/EDITOR,
falls back to xdg-open / open / startfile (non-blocking).
"""
path = resolve_config_path(config_path)
open_in_editor(path)
click.echo(f"edited {path}")
outcome = open_in_editor(path, allow_system_open=True)
if outcome == "system":
click.secho(
f"opened {path} with system default (not waiting for the app to exit)",
fg="yellow",
err=True,
)
else:
click.echo(f"edited {path}")
if __name__ == "__main__":
+128 -39
View File
@@ -4,8 +4,9 @@ import contextlib
import os
import shlex
import subprocess
import sys
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal
import click
@@ -14,14 +15,18 @@ from plyngent import config as config_mod
if TYPE_CHECKING:
from plyngent.config.store import ConfigStore
type OpenOutcome = Literal["waited", "system"]
_MINIMAL_CONFIG = """\
# plyngent configuration
# edit providers below
# Optional: omit [database] to use ~/.local/share/plyngent/chat.db (Linux).
# Optional: omit [database] (or leave url unset) → ~/.local/share/plyngent/chat.db.
# url = ":memory:" keeps a true in-memory SQLite (CLI warns; nothing on disk).
# [database]
# implementation = "sqlite"
# url = "/path/to/chat.db"
# # url = ":memory:"
# [agent]
# system_prompt = "You are a careful coding assistant."
@@ -52,9 +57,16 @@ _MINIMAL_CONFIG = """\
def get_editor() -> str | None:
"""Return the ``EDITOR`` environment value, or ``None`` if unset/empty."""
value = os.environ.get("EDITOR", "").strip()
return value or None
"""Return ``$VISUAL`` or ``$EDITOR``, or ``None`` if both unset/empty.
``VISUAL`` is preferred when set (common Unix convention for full-screen
editors); otherwise ``EDITOR``.
"""
for key in ("VISUAL", "EDITOR"):
value = os.environ.get(key, "").strip()
if value:
return value
return None
def resolve_config_path(config_path: Path | None) -> Path:
@@ -71,31 +83,14 @@ def ensure_config_file(path: Path) -> None:
_ = path.write_text(_MINIMAL_CONFIG, encoding="utf-8")
def open_in_editor(
path: Path,
*,
editor: str | None = None,
ensure_exists: bool = True,
) -> None:
"""Open ``path`` with ``EDITOR`` (supports values like ``codium --wait``).
When ``ensure_exists`` is true (default), create a minimal config template
if the file is missing (used for ``plyngent config edit``).
"""
editor_cmd = editor if editor is not None else get_editor()
if editor_cmd is None:
msg = "EDITOR is not set"
raise click.ClickException(msg)
if ensure_exists:
ensure_config_file(path)
def _run_blocking_editor(editor_cmd: str, path: Path) -> None:
try:
argv = [*shlex.split(editor_cmd, posix=os.name != "nt"), str(path)]
except ValueError as exc:
msg = f"invalid EDITOR value {editor_cmd!r}: {exc}"
msg = f"invalid editor value {editor_cmd!r}: {exc}"
raise click.ClickException(msg) from exc
if not argv:
msg = "EDITOR is empty after parsing"
msg = "editor command is empty after parsing"
raise click.ClickException(msg)
try:
@@ -112,15 +107,97 @@ def open_in_editor(
raise click.ClickException(msg)
def edit_text_in_editor(initial: str = "", *, suffix: str = ".md") -> str | None:
"""Edit ``initial`` in ``$EDITOR``; return text or ``None`` if empty/cancelled.
def _open_with_system_default(path: Path) -> None:
"""Open *path* with the OS file association (non-blocking).
Uses a temporary file. Does not create a config template.
Linux: ``xdg-open``; macOS: ``open``; Windows: ``os.startfile``.
Does not wait for the application to exit.
"""
resolved = str(path.resolve())
if sys.platform == "win32":
try:
os.startfile(resolved) # type: ignore[attr-defined]
except OSError as exc:
msg = f"failed to open with system default: {exc}"
raise click.ClickException(msg) from exc
return
if sys.platform == "darwin":
argv = ["open", resolved]
else:
# Linux and other POSIX: Free Desktop opener
argv = ["xdg-open", resolved]
try:
completed = subprocess.run(
argv,
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError as exc:
cmd = argv[0]
msg = (
f"{cmd} not found and neither VISUAL nor EDITOR is set; "
"set VISUAL or EDITOR to a blocking editor (e.g. nano, vim, codium --wait)"
)
raise click.ClickException(msg) from exc
except OSError as exc:
msg = f"failed to open with system default: {exc}"
raise click.ClickException(msg) from exc
if completed.returncode != 0:
msg = f"system open failed (exit {completed.returncode}); set VISUAL or EDITOR to a blocking editor"
raise click.ClickException(msg)
def open_in_editor(
path: Path,
*,
editor: str | None = None,
ensure_exists: bool = True,
allow_system_open: bool = True,
) -> OpenOutcome:
"""Open ``path`` for editing.
Prefer a blocking editor (``editor`` arg, else ``$VISUAL`` / ``$EDITOR``).
When none is set and ``allow_system_open`` is true, fall back to the OS
default association (``xdg-open`` / ``open`` / ``os.startfile``) — this
does **not** wait for the app to exit.
Returns:
``"waited"`` if a blocking editor ran to completion;
``"system"`` if the OS opener was used (non-blocking).
When ``ensure_exists`` is true (default), create a minimal config template
if the file is missing (used for ``plyngent config edit``).
"""
if ensure_exists:
ensure_config_file(path)
editor_cmd = editor if editor is not None else get_editor()
if editor_cmd is not None:
_run_blocking_editor(editor_cmd, path)
return "waited"
if not allow_system_open:
msg = "neither VISUAL nor EDITOR is set"
raise click.ClickException(msg)
_open_with_system_default(path)
return "system"
def edit_text_in_editor(initial: str = "", *, suffix: str = ".md") -> str | None:
"""Edit ``initial`` in a blocking editor; return text or ``None`` if empty.
Uses a temporary file. Requires ``$VISUAL`` or ``$EDITOR`` (no system-open
fallback — we must wait for the process and re-read the buffer).
"""
import tempfile
if get_editor() is None:
msg = "EDITOR is not set; cannot /edit"
msg = "neither VISUAL nor EDITOR is set; cannot /edit"
raise click.ClickException(msg)
with tempfile.NamedTemporaryFile(
@@ -134,7 +211,7 @@ def edit_text_in_editor(initial: str = "", *, suffix: str = ".md") -> str | None
_ = handle.write(initial)
try:
open_in_editor(path, ensure_exists=False)
_ = open_in_editor(path, ensure_exists=False, allow_system_open=False)
text = path.read_text(encoding="utf-8")
finally:
with contextlib.suppress(OSError):
@@ -146,19 +223,24 @@ def edit_text_in_editor(initial: str = "", *, suffix: str = ".md") -> str | None
return cleaned
def prompt_edit_config(path: Path, *, reason: str | None = None) -> bool:
"""If ``EDITOR`` is set, ask whether to edit ``path``. Returns True if opened."""
if get_editor() is None:
return False
def prompt_edit_config(path: Path, *, reason: str | None = None) -> OpenOutcome | None:
"""Ask whether to edit ``path``. Returns open outcome, or ``None`` if skipped.
Offers when a blocking editor is set **or** system open can be attempted.
"""
has_editor = get_editor() is not None
# System open is always attempted as fallback when no editor; we still
# prompt so the user can decline on headless hosts.
message = f"{reason} Edit config file {path}?" if reason else f"Edit config file {path}?"
if not has_editor:
message = f"{message} (no VISUAL/EDITOR; will try system default open — non-blocking)"
if not click.confirm(message, default=False):
return False
open_in_editor(path)
return True
return None
return open_in_editor(path, allow_system_open=True)
def load_config_with_optional_edit(config_path: Path | None) -> ConfigStore:
"""Load config; if there are no providers and EDITOR is set, offer to edit and reload.
"""Load config; if there are no providers, offer to edit and reload when waited.
Raises:
config_mod.ConfigFormatError: Invalid TOML (caller should surface path).
@@ -170,6 +252,13 @@ def load_config_with_optional_edit(config_path: Path | None) -> ConfigStore:
reason = "No providers configured."
if not path.exists():
reason = f"Config file not found ({path})."
if prompt_edit_config(path, reason=reason):
outcome = prompt_edit_config(path, reason=reason)
if outcome == "waited":
store = config_mod.load(path)
elif outcome == "system":
click.secho(
f"opened {path} with system default (not waiting). Save the file, then re-run plyngent to load providers.",
fg="yellow",
err=True,
)
return store
+21 -9
View File
@@ -4,7 +4,6 @@ import asyncio
import contextlib
import signal
from contextlib import contextmanager
from contextvars import ContextVar
from typing import TYPE_CHECKING
if TYPE_CHECKING:
@@ -13,13 +12,16 @@ if TYPE_CHECKING:
type SigHandler = Callable[[int, FrameType | None], None] | int | None
_allow_task_cancel: ContextVar[bool] = ContextVar("allow_task_cancel", default=True)
# Depth of nested pause_task_cancel_for_prompt. Must be a plain int (not ContextVar):
# asyncio.add_signal_handler freezes the ContextVar snapshot at install time, so a
# ContextVar reset after reinstall would leave SIGINT permanently non-cancelling.
_prompt_depth: int = 0
_reinstall_holder: list[Callable[[], None] | None] = [None]
def allow_task_cancel() -> bool:
"""Whether the CLI SIGINT handler should cancel the in-flight turn task."""
return _allow_task_cancel.get()
return _prompt_depth == 0
def set_sigint_reinstall(callback: Callable[[], None] | None) -> None:
@@ -34,8 +36,14 @@ def pause_task_cancel_for_prompt() -> Generator[None]:
Must run on the main thread (signal handlers are main-thread only).
Restores the default SIGINT handler so ``click.confirm`` can receive
KeyboardInterrupt / Abort instead of the asyncio turn being cancelled.
Nested prompts are supported via a depth counter. The asyncio SIGINT
handler is reinstalled only after depth returns to 0, and only after
cancel is re-enabled, so the handler never freezes ``allow=False``.
"""
token = _allow_task_cancel.set(False)
global _prompt_depth # noqa: PLW0603
_prompt_depth += 1
loop_handler_removed = False
previous: SigHandler = signal.SIG_DFL
try:
@@ -56,11 +64,15 @@ def pause_task_cancel_for_prompt() -> Generator[None]:
finally:
with contextlib.suppress(ValueError):
_ = signal.signal(signal.SIGINT, previous)
reinstall = _reinstall_holder[0]
if loop_handler_removed and reinstall is not None:
with contextlib.suppress(RuntimeError, NotImplementedError, ValueError):
reinstall()
_allow_task_cancel.reset(token)
_prompt_depth = max(0, _prompt_depth - 1)
# Reinstall only when fully out of prompts and cancel is allowed again.
# Order matters: decrement depth first so allow_task_cancel() is True
# before reinstall (and handlers never freeze allow=False).
if _prompt_depth == 0 and loop_handler_removed:
reinstall = _reinstall_holder[0]
if reinstall is not None:
with contextlib.suppress(RuntimeError, NotImplementedError, ValueError):
reinstall()
async def run_in_prompt_thread[**P, R](func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
+206 -16
View File
@@ -1,24 +1,102 @@
from __future__ import annotations
import contextlib
import selectors
import shutil
import sys
from typing import TYPE_CHECKING, Literal
from plyngent.cli.interrupt import pause_task_cancel_for_prompt
from plyngent.prompting import (
ChoiceOption,
NonInteractiveError,
ask,
ask_async,
choose,
choose_async,
configure_prompting,
confirm,
confirm_async,
get_prompt_backend,
)
from plyngent.tools.process.pty_session import PtyManager
if TYPE_CHECKING:
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"]
_BOX_MIN_WIDTH = 40
_BOX_MAX_WIDTH = 100
_BOX_PAD = 2 # spaces inside left/right borders
def _terminal_width() -> int:
try:
return max(_BOX_MIN_WIDTH, min(_BOX_MAX_WIDTH, shutil.get_terminal_size(fallback=(80, 24)).columns))
except OSError:
return 80
def _wrap_line(text: str, width: int) -> list[str]:
"""Hard-wrap a single logical line to *width* (no word-break library)."""
width = max(width, 8)
if not text:
return [""]
if len(text) <= width:
return [text]
out: list[str] = []
rest = text
while rest:
if len(rest) <= width:
out.append(rest)
break
# Prefer break at last space in the window.
window = rest[:width]
break_at = window.rfind(" ")
if break_at >= width // 2:
out.append(rest[:break_at])
rest = rest[break_at + 1 :]
else:
out.append(rest[:width])
rest = rest[width:]
return out
def format_tool_confirm_box(name: str, reason: str) -> str:
"""Multi-line boxed confirm body (header + reason lines).
Printed with :meth:`PromptBackend.echo` before a short ``confirm()`` prompt
so terminals keep newlines (unlike a single jammed readline line).
"""
term = _terminal_width()
inner = max(20, term - 4) # room for ``│ `` + `` │``
header = f"confirm · tool {name!r}"
body_lines: list[str] = []
for raw in reason.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
body_lines.extend(_wrap_line(raw, inner - _BOX_PAD))
content = [header, "" * min(inner, max(len(header), 12)), *body_lines]
width = min(inner, max(len(line) for line in content) + _BOX_PAD)
width = max(width, min(inner, len(header) + _BOX_PAD))
top = "" + "" * (width + 2) + ""
bottom = "" + "" * (width + 2) + ""
rows = [top]
for line in content:
# Second line is a separator drawn with box dashes already in content.
if line.startswith("") and set(line) <= {""}:
rows.append("" + "" * (width + 2) + "")
continue
padded = line[:width].ljust(width)
rows.append(f"{padded}")
rows.append(bottom)
return "\n".join(rows)
def _echo_tool_confirm(name: str, reason: str) -> None:
backend = get_prompt_backend()
backend.echo()
backend.secho(format_tool_confirm_box(name, reason), fg="yellow")
backend.echo()
def _prompt_continue_limit_sync(reason: str) -> bool:
try:
@@ -44,33 +122,142 @@ async def prompt_continue_limit_async(reason: str) -> bool:
return False
def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool:
def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool | str:
"""True allow; False deny; non-empty str = deny with comment for the model."""
del args
try:
return confirm(
f"[confirm] tool {name!r}: {reason}\nAllow this tool call?",
default=False,
)
_echo_tool_confirm(name, reason)
allowed = confirm("Allow this tool call?", default=False)
except NonInteractiveError:
return False
if allowed:
return True
try:
comment = ask(
"Optional comment for the agent (why denied; empty to skip):",
default="",
).strip()
except NonInteractiveError:
return False
return comment or False
def prompt_confirm_tool(name: str, args: Mapping[str, object], reason: str) -> bool:
"""Ask whether to allow a destructive tool call (TTY). Default is deny."""
def prompt_confirm_tool(name: str, args: Mapping[str, object], reason: str) -> bool | str:
"""Ask whether to allow a dangerous tool call (TTY). Default is deny.
Prints a multi-line boxed summary, then a short y/N prompt. On deny,
optionally collect a free-text comment for the model.
"""
with pause_task_cancel_for_prompt():
return _prompt_confirm_tool_sync(name, args, reason)
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool:
"""Async variant: confirm off the event loop."""
def _try_readline() -> str | None:
try:
return sys.stdin.readline()
except OSError, EOFError:
return None
def _read_yes_no_line_with_timeout(timeout_seconds: float) -> str | None:
"""Read one line from stdin with *timeout_seconds*; ``None`` on timeout/EOF.
Uses :mod:`selectors` on the process stdin FD. On Windows without selector
support, falls back to blocking readline (no true timeout).
"""
try:
if not sys.stdin.isatty():
return None
fd = sys.stdin.fileno()
except OSError, ValueError, AttributeError:
return _try_readline()
try:
selector = selectors.DefaultSelector()
_ = selector.register(fd, selectors.EVENT_READ)
except OSError, ValueError, AttributeError:
return _try_readline()
try:
events = selector.select(timeout_seconds)
if not events:
return None
return sys.stdin.readline()
except OSError, ValueError, EOFError:
return None
finally:
selector.close()
def prompt_policy_command_confirm(
basename: str,
argv: Sequence[str],
timeout_seconds: float,
) -> bool:
"""Ask whether to allow a denylisted command for this session (timed).
Independent of YOLO: always prompts when interactive. Default is **deny**.
Timeout, cancel, or non-interactive → False.
"""
argv_preview = " ".join(str(part) for part in argv)
reason = (
f"policy denylist blocks basename {basename!r}\n"
f"argv: {argv_preview}\n"
f"Allow this basename for the rest of this process?\n"
f"(timeout {timeout_seconds:g}s defaults to DENY; not skipped by YOLO)"
)
try:
with pause_task_cancel_for_prompt():
backend = get_prompt_backend()
if not backend.is_interactive():
return False
backend.echo()
backend.secho(format_tool_confirm_box("policy", reason), fg="yellow")
backend.echo()
prompt = f"[policy] allow {basename!r}? [y/N] (timeout {timeout_seconds:g}s): "
with contextlib.suppress(OSError):
_ = sys.stderr.write(prompt)
_ = sys.stderr.flush()
raw = _read_yes_no_line_with_timeout(timeout_seconds)
if raw is None:
with contextlib.suppress(OSError, NonInteractiveError):
backend.secho(
f"[policy] timed out after {timeout_seconds:g}s — denied",
fg="red",
err=True,
)
return False
return raw.strip().lower() in {"y", "yes"}
except NonInteractiveError, KeyboardInterrupt, EOFError:
return False
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool | str:
"""Async confirm: True allow, False deny, str = deny with user comment."""
del args
try:
return await confirm_async(
f"[confirm] tool {name!r}: {reason}\nAllow this tool call?",
default=False,
)
# Box is printed inside the worker thread via confirm's backend.
def _run() -> bool:
_echo_tool_confirm(name, reason)
return confirm("Allow this tool call?", default=False)
from plyngent.prompting import run_prompt_async
allowed = await run_prompt_async(_run)
except NonInteractiveError:
return False
if allowed:
return True
try:
comment = (
await ask_async(
"Optional comment for the agent (why denied; empty to skip):",
default="",
)
).strip()
except NonInteractiveError:
return False
return comment or False
def _prompt_workspace_mismatch_sync(
@@ -163,6 +350,9 @@ async def prompt_workspace_mismatch_async(
def install_cli_limit_hooks() -> None:
"""Register interactive continue hooks and prompt cancel-pause for the CLI."""
"""Register interactive continue hooks, policy confirm, and prompt cancel-pause."""
configure_prompting(pause_factory=pause_task_cancel_for_prompt)
PtyManager.set_limit_continue_hook(prompt_continue_limit)
from plyngent.tools.workspace import set_policy_confirm_hook
set_policy_confirm_hook(prompt_policy_command_confirm)
+43 -3
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import inspect
from typing import TYPE_CHECKING, Literal, Protocol, cast, runtime_checkable
@@ -10,6 +11,8 @@ if TYPE_CHECKING:
# Cache remote catalog this long (seconds) unless /models --refresh.
DEFAULT_MODELS_CACHE_TTL = 300.0
# Bound startup/interactive GET /models so a dead API cannot hang the CLI.
DEFAULT_MODELS_FETCH_TIMEOUT = 5.0
type ModelListPrefer = Literal["remote", "union", "config"]
@@ -58,21 +61,58 @@ def client_supports_models(client: object) -> bool:
return isinstance(client, SupportsModels) or callable(getattr(client, "models", None))
async def fetch_remote_model_ids(client: object) -> list[str]:
"""Call ``client.models()``; raise if missing or the call fails."""
async def fetch_remote_model_ids(
client: object,
*,
timeout_seconds: float = DEFAULT_MODELS_FETCH_TIMEOUT,
) -> list[str]:
"""Call ``client.models()``; raise if missing or the call fails.
*timeout_seconds* bounds the await; use ``0`` or less to wait indefinitely.
"""
method = getattr(client, "models", None)
if not callable(method):
msg = "client does not support listing models"
raise TypeError(msg)
result = method()
if inspect.isawaitable(result):
result = await result
try:
if timeout_seconds > 0:
async with asyncio.timeout(timeout_seconds):
result = await result
else:
result = await result
except TimeoutError as exc:
msg = f"models() timed out after {timeout_seconds}s"
raise RuntimeError(msg) from exc
except asyncio.CancelledError as exc:
# SSL/HTTP stacks may surface cancel instead of TimeoutError; treat as soft fail.
msg = "models() was cancelled or timed out"
raise RuntimeError(msg) from exc
if not isinstance(result, list):
msg = f"models() returned unexpected type {type(result)!r}"
raise TypeError(msg)
return [str(item) for item in cast("list[object]", result) if item]
def needs_remote_models_for_selection(
provider: Provider,
*,
preferred_model: str | None,
interactive: bool,
) -> bool:
"""True when interactive model pick needs a remote catalog for a better list.
Skip network when the model is already known (``--model`` / session) or when
config has exactly one model (auto-selected).
"""
if preferred_model is not None and preferred_model.strip():
return False
if not interactive:
return False
return len(config_model_ids(provider)) != 1
def model_choices_for_provider(
provider: Provider,
*,
+1 -1
View File
@@ -22,7 +22,7 @@ async def discover_model_ids(provider: Provider) -> list[str]:
return []
try:
return await fetch_remote_model_ids(client)
except RuntimeError, TypeError, OSError, ValueError:
except RuntimeError, TypeError, OSError, ValueError, TimeoutError:
return []
+4 -2
View File
@@ -54,8 +54,10 @@ def build_completer(state: ReplState) -> Callable[[str, int], str | None]:
options = filter_prefix(text, slash_commands())
else:
head = buffer[:begidx].strip()
command = head.split()[0] if head else ""
options = complete_slash_args(state, command, text)
tokens = head.split() if head else []
command = tokens[0] if tokens else ""
prior_args = tokens[1:] if len(tokens) > 1 else []
options = complete_slash_args(state, command, text, prior_args=prior_args)
if state_index < len(options):
return options[state_index]
return None
+3
View File
@@ -66,6 +66,9 @@ async def run_cancellable[T](coro: Coroutine[object, object, T]) -> T:
installed = False
def _on_sigint() -> None:
# allow_task_cancel() uses a process-level depth counter (not ContextVar)
# so this remains correct even if the handler was installed under a
# frozen context (asyncio signal handles capture contextvars).
if allow_task_cancel() and not task.done():
_ = task.cancel()
+429 -58
View File
@@ -15,7 +15,8 @@ from plyngent.cli.selection import select_model, select_provider
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
AssistantFunctionToolCall,
ToolChatMessage,
DeveloperChatMessage,
SystemChatMessage,
UserChatMessage,
)
from plyngent.runtime import ProviderNotSupportedError
@@ -32,18 +33,61 @@ _COMPACT_PREVIEW = 400
_ON_OFF_CHOICES = ("on", "off")
_YOLO_MODE_CHOICES = ("on", "off", "once")
_EXPORT_FORMAT_CHOICES = ("md", "json")
_TODO_ACTION_CHOICES = (
"list",
"show",
"ls",
"push",
"pop",
"done",
"cancel",
"pending",
"in_progress",
"clear",
)
_ROUNDS_CHOICES = ("8", "16", "32", "64", "128")
class HistoryLimitType(click.ParamType[int]):
"""``N`` (int >= 1) or the shortcut ``last`` (equivalent to ``1``)."""
name: str = "history_limit"
@override
def convert(self, value: object, param: click.Parameter | None, ctx: click.Context | None) -> int:
if isinstance(value, int):
if value < 1:
self.fail("must be >= 1", param, ctx)
return value
text = str(value).strip().lower()
if text == "last":
return 1
try:
n = int(text, 10)
except ValueError:
self.fail("expected an integer N or 'last'", param, ctx)
if n < 1:
self.fail("must be >= 1", param, ctx)
return n
@override
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
del ctx, param
tokens = ("last", "1", "5", "10", "20")
return [CompletionItem(token) for token in tokens if incomplete == "" or token.startswith(incomplete.lower())]
HELP_FOOTER = (
"User messages are saved immediately. On API errors or Ctrl+C, partial\n"
"assistant/tool output is discarded but the user message stays (so /retry\n"
"works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n"
"\n"
"Tab completes slash commands and some arguments (provider, model, tools,\n"
"stream, verbose, yolo, export). Use --session ID or /resume to continue a prior\n"
"chat after restart.\n"
"Tab completes slash commands and arguments (provider, model, session ids,\n"
"todos actions, on/off, yolo, export, flags). Use --session ID or /resume to\n"
"continue a prior chat after restart.\n"
"\n"
'Multiline: start a message with """ then end a later line with """.\n'
"Long prompts: /edit opens $EDITOR.\n"
"Long prompts: /edit opens $VISUAL/$EDITOR (blocking).\n"
)
@@ -130,6 +174,80 @@ class ExportFormatParam(click.ParamType[str]):
EXPORT_FORMAT = ExportFormatParam()
class TodosActionParam(click.ParamType[str]):
"""First token of ``/todos``: list|push|pop|done|…"""
name: str = "todos_action"
@override
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> str:
del param, ctx
return str(value)
@override
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
del ctx, param
return _filter_choices(incomplete, _TODO_ACTION_CHOICES)
TODOS_ACTION = TodosActionParam()
class SessionIdParam(click.ParamType[int]):
"""Session id for ``/resume`` / ``/delete`` (from ReplState cache + current)."""
name: str = "session_id"
@override
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> int:
if isinstance(value, int):
return value
text = str(value).strip()
try:
return int(text, 10)
except ValueError:
self.fail("expected a session id (integer)", param, ctx)
@override
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
del param
state = _repl_state(ctx)
if state is None:
return []
# Sync cache only — no async from readline Tab (no awaitlet greenlet).
return _filter_choices(incomplete, state.session_ids_for_complete())
SESSION_ID = SessionIdParam()
class RoundsParam(click.ParamType[int]):
"""Max tool-loop rounds (int >= 1); Tab suggests common values."""
name: str = "rounds"
@override
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> int:
if isinstance(value, int):
n = value
else:
try:
n = int(str(value).strip(), 10)
except ValueError:
self.fail("expected an integer >= 1", param, ctx)
if n < 1:
self.fail("max_rounds must be >= 1", param, ctx)
return n
@override
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
del ctx, param
return _filter_choices(incomplete, _ROUNDS_CHOICES)
ROUNDS = RoundsParam()
class ProviderNameParam(click.ParamType[str]):
name: str = "provider"
@@ -232,21 +350,55 @@ def slash_command_names() -> list[str]:
return sorted(f"/{name}" for name in slash.list_commands(ctx))
def complete_slash_args(state: ReplState, command: str, incomplete: str) -> list[str]:
"""Tab-complete arguments for ``command`` (e.g. ``/stream``) from ParamTypes.
def complete_slash_args(
state: ReplState,
command: str,
incomplete: str,
*,
prior_args: Sequence[str] | None = None,
) -> list[str]:
"""Tab-complete arguments/options for ``command`` from ParamTypes.
Uses the first :class:`click.Argument` on the registered command whose type
implements :meth:`~click.ParamType.shell_complete` with candidates.
*prior_args* are tokens already typed after the command name (so
``/todos done t`` can complete todo ids). Options are completed when
*incomplete* starts with ``-``.
"""
name = command.lstrip("/").lower()
ctx = click.Context(slash, obj=state)
cmd = slash.get_command(ctx, name)
if cmd is None:
return []
prior = list(prior_args or ())
with click.Context(cmd, info_name=name, parent=ctx, obj=state) as sub:
for param in cmd.params:
if not isinstance(param, click.Argument):
continue
# Flag / option completion (e.g. --persist, --full).
if incomplete.startswith("-"):
flags = [
opt
for param in cmd.params
if isinstance(param, click.Option)
for opt in param.opts
if opt.startswith(incomplete)
]
if flags:
return sorted(set(flags))
# Contextual /todos second token: todo ids after done|cancel|pending|in_progress.
if name == "todos" and prior:
action = prior[0].lower()
if action in {"done", "cancel", "pending", "in_progress"} and len(prior) == 1:
ids = [item.id for item in state.todo_stack.all_items()]
return [i for i in ids if i.startswith(incomplete)]
# Positional arguments: complete the next unset argument.
arg_params = [p for p in cmd.params if isinstance(p, click.Argument)]
arg_index = len(prior)
if arg_index < len(arg_params):
param = arg_params[arg_index]
items = param.type.shell_complete(sub, param, incomplete)
if items:
return [item.value for item in items]
for param in arg_params:
items = param.type.shell_complete(sub, param, incomplete)
if items:
return [item.value for item in items]
@@ -301,10 +453,10 @@ def clear_cmd(state: ReplState) -> None:
@slash.command("edit")
@click.pass_obj
def edit_cmd(state: ReplState) -> None:
"""Compose a user message in ``$EDITOR``, then send it.
"""Compose a user message in ``$VISUAL``/``$EDITOR``, then send it.
Opens a temporary buffer; save and quit the editor to submit.
Empty buffer cancels. Requires ``EDITOR`` (e.g. ``codium --wait``).
Empty buffer cancels. Requires a blocking editor (no system-open fallback).
"""
from plyngent.cli.editor import edit_text_in_editor
@@ -323,20 +475,27 @@ def edit_cmd(state: ReplState) -> None:
@slash.command("config")
@click.pass_obj
def config_cmd(state: ReplState) -> None:
"""Open plyngent.toml in ``$EDITOR``, then reload providers/agent settings.
"""Open plyngent.toml in ``$VISUAL``/``$EDITOR`` (or system default), then reload.
Same file as ``plyngent config edit``. After the editor exits, config is
re-read; current provider/model are kept when still valid.
Same file as ``plyngent config edit``. After a **blocking** editor exits,
config is re-read. System-open fallback does not wait — reload is skipped
with a hint to re-run ``/config`` after saving.
"""
from plyngent import config as config_mod
from plyngent.cli.editor import open_in_editor
path = state.config.path
try:
open_in_editor(path)
outcome = open_in_editor(path, allow_system_open=True)
except click.ClickException as exc:
click.echo(f"error: {exc}")
return
if outcome == "system":
click.secho(
f"opened {path} with system default (not waiting). Save the file, then run /config again to reload.",
fg="yellow",
)
return
try:
state.reload_config_from_disk()
except (config_mod.ConfigFormatError, ValueError, OSError) as exc:
@@ -398,6 +557,7 @@ def status_cmd(state: ReplState) -> None:
def sessions_cmd(state: ReplState) -> None:
"""List sessions for this workspace (newest first)."""
sessions = _await(state.memory.list_sessions(workspace=state.workspace))
state.remember_session_ids([s.sid for s in sessions])
if not sessions:
click.echo(f"(no sessions for workspace {state.workspace})")
return
@@ -435,7 +595,7 @@ def rename_cmd(state: ReplState, name: tuple[str, ...]) -> None:
@slash.command("delete")
@click.argument("session_id", type=int, required=False)
@click.argument("session_id", type=SESSION_ID, required=False)
@click.pass_obj
def delete_cmd(state: ReplState, session_id: int | None) -> None:
"""Hard-delete a session (confirm; current → new empty)."""
@@ -538,7 +698,7 @@ def export_cmd(state: ReplState, parts: tuple[str, ...]) -> None:
@slash.command("resume")
@click.argument("session_id", type=int, required=False)
@click.argument("session_id", type=SESSION_ID, required=False)
@click.pass_obj
def resume_cmd(state: ReplState, session_id: int | None) -> None:
"""Resume session id, or latest for this workspace if omitted."""
@@ -576,6 +736,11 @@ def compact_cmd(state: ReplState, name: str | None) -> None:
return
preview = summary if len(summary) <= _COMPACT_PREVIEW else summary[:_COMPACT_PREVIEW] + ""
click.echo(f"compacted session {old_id} -> new session {new_id}")
click.secho(
f"active session is summary-only ({len(state.agent.messages)} messages); "
f"full history remains on /resume {old_id}",
fg="bright_black",
)
click.secho(preview, fg="bright_black")
@@ -637,8 +802,13 @@ def provider_cmd(state: ReplState, name: str | None) -> None:
@slash.command("models")
@click.option("--refresh", is_flag=True, help="Bypass cache and re-fetch GET /models.")
@click.option(
"--persist",
is_flag=True,
help="Merge remote (and recovered) model ids into plyngent.toml for this provider.",
)
@click.pass_obj
def models_cmd(state: ReplState, *, refresh: bool) -> None:
def models_cmd(state: ReplState, *, refresh: bool, persist: bool) -> None: # noqa: C901
"""List models (remote-first, plus config-only ids). Always tries GET /models."""
del refresh # always re-fetch; flag kept for CLI compatibility / docs
remote: list[str] | None = None
@@ -662,6 +832,14 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
except (KeyError, ValueError) as exc:
click.secho(f"could not recover provider: {exc}", fg="yellow", err=True)
if persist:
try:
path = state.persist_models_to_config(mode="catalog", catalog_ids=remote or ())
click.echo(f"persisted {len(state.provider.models)} model(s) for {state.provider_name!r} to {path}")
except (KeyError, ValueError, OSError) as exc:
click.secho(f"error: could not persist models: {exc}", fg="red")
return
config_ids = set(state.config_model_ids())
choices = model_choices_for_provider(state.provider, remote_ids=remote)
@@ -691,24 +869,43 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
@slash.command("model")
@click.argument("model_id", required=False, type=MODEL_ID)
@click.option(
"--persist",
is_flag=True,
help="Write the current (or newly selected) model id into plyngent.toml.",
)
@click.pass_obj
def model_cmd(state: ReplState, model_id: str | None) -> None:
"""Show or switch model (Tab: remote-first plus config; live fetch on pick)."""
if not model_id:
def model_cmd(state: ReplState, model_id: str | None, *, persist: bool) -> None:
"""Show or switch model (Tab: remote-first plus config; live fetch on pick).
``/model --persist`` saves the active model id into the provider catalog in
TOML (faster Tab/list on next launch). ``/model <id> --persist`` switches
then saves.
"""
if model_id:
try:
choices = _await(state.merged_model_choices(refresh=True))
state.model = select_model(
state.provider,
preferred=model_id.strip(),
choices=choices,
)
state.rebuild_client()
_await(state.persist_llm_selection())
click.echo(f"switched model to {state.model}")
except click.ClickException as exc:
click.echo(f"error: {exc}")
return
elif not persist:
click.echo(f"model={state.model}")
return
try:
choices = _await(state.merged_model_choices(refresh=True))
state.model = select_model(
state.provider,
preferred=model_id.strip(),
choices=choices,
)
state.rebuild_client()
_await(state.persist_llm_selection())
click.echo(f"switched model to {state.model}")
except click.ClickException as exc:
click.echo(f"error: {exc}")
if persist:
try:
path = state.persist_models_to_config(mode="current")
click.echo(f"persisted model {state.model!r} for {state.provider_name!r} to {path}")
except (KeyError, ValueError, OSError) as exc:
click.secho(f"error: could not persist model: {exc}", fg="red")
@slash.command("tools")
@@ -800,43 +997,158 @@ def markdown_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT00
@slash.command("rounds")
@click.argument("n", required=False, type=int)
@click.argument("n", required=False, type=ROUNDS)
@click.pass_obj
def rounds_cmd(state: ReplState, n: int | None) -> None:
"""Show or set max tool-loop rounds."""
if n is None:
click.echo(f"max_rounds={state.max_rounds}")
return
if n < 1:
msg = "max_rounds must be >= 1"
raise click.UsageError(msg)
state.max_rounds = n
state.agent.max_rounds = n
click.echo(f"max_rounds={state.max_rounds}")
@slash.command("history")
@click.argument("n", required=False, type=int)
@click.argument("n", required=False, type=HistoryLimitType())
@click.option(
"--full",
"mode_full",
is_flag=True,
default=False,
help="Print full message bodies (markdown for assistant when TTY).",
)
@click.option(
"--preview",
"mode_preview",
is_flag=True,
default=False,
help="Force short previews even for a single message.",
)
@click.pass_obj
def history_cmd(state: ReplState, n: int | None) -> None:
"""Show last n messages in this session (default 20)."""
limit = _DEFAULT_HISTORY_LINES if n is None else n
if limit < 1:
msg = "n must be >= 1"
def history_cmd(
state: ReplState,
n: int | None,
*,
mode_full: bool,
mode_preview: bool,
) -> None:
"""Show recent messages (default: last 20 as short previews).
``/history last`` or ``/history 1`` shows the last message in full with Rich
markdown when available. Use ``--full`` for multiple messages in full;
``--preview`` forces the short form even for a single message.
"""
if mode_full and mode_preview:
msg = "use only one of --full / --preview"
raise click.UsageError(msg)
limit = _DEFAULT_HISTORY_LINES if n is None else n
messages = state.agent.messages
if not messages:
click.echo("(no messages in this session)")
return
start = max(0, len(messages) - limit)
click.echo(f"session={state.session_id} messages={len(messages)} showing={len(messages) - start}")
for offset, message in enumerate(messages[start:]):
click.echo(_format_history_message(start + offset, message))
slice_msgs = messages[start:]
# Full: --full, or single-message window (last / 1) unless --preview.
full = mode_full or (len(slice_msgs) == 1 and not mode_preview)
mode_tag = "full" if full else "preview"
click.echo(f"session={state.session_id} messages={len(messages)} showing={len(slice_msgs)} mode={mode_tag}")
for offset, message in enumerate(slice_msgs):
idx = start + offset
if full:
_print_history_message_full(idx, message)
else:
click.echo(_format_history_message(idx, message))
if state.agent.pending_retry_text is not None:
click.secho(
f"(pending retry) user: {_preview_content(state.agent.pending_retry_text)}",
fg="yellow",
)
pending = state.agent.pending_retry_text
if full:
click.secho("(pending retry) user:", fg="yellow")
click.echo(pending)
else:
click.secho(
f"(pending retry) user: {_preview_content(pending)}",
fg="yellow",
)
@slash.command("todos")
@click.argument("action", required=False, type=TODOS_ACTION)
@click.argument("rest", required=False, nargs=-1, type=str)
@click.pass_obj
def todos_cmd( # noqa: C901, PLR0911, PLR0912, PLR0915
state: ReplState, action: str | None, rest: tuple[str, ...]
) -> None:
"""Show or edit the LIFO stack of **task groups**.
``/todos`` — list (top group first)
``/todos push T1; T2`` — one new group of siblings
``/todos pop`` — pop entire top group
``/todos done <id>`` / ``/todos cancel <id>`` — set status
``/todos clear`` — wipe stack
"""
from plyngent.agent.todo_stack import parse_push_titles
stack = state.todo_stack
act = (action or "list").strip().lower()
if act in {"list", "show", "ls"}:
click.echo(stack.render())
return
if act == "push":
raw = " ".join(rest).strip()
if not raw:
click.echo('error: usage: /todos push <title> | /todos push ["T1","T2"] | T1; T2')
return
titles = parse_push_titles(raw)
if not titles:
click.echo("error: no titles to push (use a JSON array or ; / newlines)")
return
try:
group = stack.push_group(titles)
except ValueError as exc:
click.echo(f"error: {exc}")
return
_await(state.persist_todo_stack())
ids = ", ".join(i.id for i in group.items)
click.echo(f"pushed group depth={stack.depth} items=[{ids}]")
click.echo(stack.render())
return
if act == "pop":
group = stack.pop()
if group is None:
click.echo("todo stack empty")
return
_await(state.persist_todo_stack())
titles = ", ".join(f"{i.id}:{i.title}" for i in group.items) or "(empty)"
click.echo(f"popped TOP group ({titles})")
click.echo(stack.render())
return
if act in {"done", "cancel", "pending", "in_progress"}:
if not rest:
click.echo(f"error: usage: /todos {act} <id>")
return
if act == "done":
new_status = "done"
elif act == "cancel":
new_status = "cancelled"
elif act == "pending":
new_status = "pending"
else:
new_status = "in_progress"
try:
item = stack.update(rest[0], status=new_status)
except (KeyError, ValueError) as exc:
click.echo(f"error: {exc}")
return
_await(state.persist_todo_stack())
click.echo(f"updated {item.id}{item.status}")
click.echo(stack.render())
return
if act == "clear":
n = stack.clear()
_await(state.persist_todo_stack())
click.echo(f"cleared {n} item(s)")
return
click.echo("error: usage: /todos [list|push|pop|done|cancel|clear] …")
@slash.command("retry")
@@ -858,9 +1170,71 @@ def _preview_content(text: str | None) -> str:
return text[:_CONTENT_PREVIEW] + ""
def _print_history_assistant_full(index: int, message: AssistantChatMessage) -> None:
from plyngent.cli.display import markdown_render_available, print_markdown
click.secho(f"{index}. assistant:", fg="cyan")
content = message.content
has_text = isinstance(content, str) and content.strip()
if has_text and isinstance(content, str):
if markdown_render_available():
print_markdown(content, label="")
else:
click.echo(content)
reasoning = message.reasoning_content
has_reason = isinstance(reasoning, str) and reasoning.strip()
if has_reason and isinstance(reasoning, str):
click.secho("reasoning:", fg="bright_black")
click.echo(reasoning)
tool_calls = message.tool_calls
has_tools = tool_calls is not UNSET and bool(tool_calls)
if has_tools and tool_calls is not UNSET:
for call in tool_calls:
if isinstance(call, AssistantFunctionToolCall):
click.secho(
f" tool_call {call.id}: {call.function.name}({call.function.arguments})",
fg="yellow",
)
else:
click.secho(f" tool_call custom id={call.id}", fg="yellow")
if not (has_text or has_reason or has_tools):
click.echo("(empty)")
click.echo()
def _print_history_message_full(index: int, message: AnyChatMessage) -> None:
"""Print one history message with full body; Rich markdown for assistant text."""
if isinstance(message, UserChatMessage):
click.secho(f"{index}. user:", fg="green")
click.echo(message.content or "")
click.echo()
return
if isinstance(message, DeveloperChatMessage):
click.secho(f"{index}. developer:", fg="blue")
click.echo(message.content or "")
click.echo()
return
if isinstance(message, SystemChatMessage):
click.secho(f"{index}. system:", fg="bright_black")
click.echo(message.content or "")
click.echo()
return
if isinstance(message, AssistantChatMessage):
_print_history_assistant_full(index, message)
return
# ToolChatMessage (remaining AnyChatMessage arm)
click.secho(f"{index}. tool({message.tool_call_id}):", fg="magenta")
click.echo(message.content or "")
click.echo()
def _format_history_message(index: int, message: AnyChatMessage) -> str:
if isinstance(message, UserChatMessage):
return f"{index}. user: {_preview_content(message.content)}"
if isinstance(message, DeveloperChatMessage):
return f"{index}. developer: {_preview_content(message.content)}"
if isinstance(message, SystemChatMessage):
return f"{index}. system: {_preview_content(message.content)}"
if isinstance(message, AssistantChatMessage):
parts: list[str] = []
if isinstance(message.content, str) and message.content:
@@ -876,11 +1250,8 @@ def _format_history_message(index: int, message: AnyChatMessage) -> str:
parts.append(f"tool_calls=[{', '.join(names)}]")
body = " ".join(parts) if parts else "(empty)"
return f"{index}. assistant: {body}"
if isinstance(message, ToolChatMessage):
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))}"
# ToolChatMessage
return f"{index}. tool({message.tool_call_id}): {_preview_content(message.content)}"
def _run_slash_argv(args: Sequence[str], state: ReplState) -> None:
+142 -6
View File
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Literal, cast
from plyngent.agent import ChatAgent, ChatClient, ToolRegistry
from plyngent.agent.loop import DEFAULT_MAX_ROUNDS
from plyngent.agent.todo_stack import TodoStack
from plyngent.cli.models_source import (
DEFAULT_MODELS_CACHE_TTL,
client_supports_models,
@@ -17,9 +18,11 @@ from plyngent.cli.models_source import (
)
from plyngent.memory.database.store import normalize_workspace
from plyngent.runtime import create_client
from plyngent.tools import DEFAULT_TOOLS, set_workspace_root
from plyngent.tools import DEFAULT_TOOLS, set_todo_stack, set_workspace_root
if TYPE_CHECKING:
from collections.abc import Sequence
from plyngent.config.models import Provider
from plyngent.config.store import ConfigStore
from plyngent.memory import MemoryStore
@@ -54,6 +57,10 @@ class ReplState:
client: ChatClient = field(init=False)
agent: ChatAgent = field(init=False)
session_id: int | None = None
todo_stack: TodoStack = field(default_factory=TodoStack)
_todo_persist_tasks: set[object] = field(default_factory=set, init=False, repr=False)
# Session ids for Tab complete (updated when listing/creating/resuming).
_session_id_cache: list[int] = field(default_factory=list, init=False, repr=False)
# Remote GET /models cache (per provider base).
_remote_models: list[str] | None = field(default=None, init=False, repr=False)
_remote_models_fetched_at: float | None = field(default=None, init=False, repr=False)
@@ -66,6 +73,7 @@ class ReplState:
self.workspace = Path(self.workspace).expanduser().resolve()
self.agent = self._make_agent()
self.sync_display_flags()
self._bind_todo_tools()
def sync_display_flags(self) -> None:
from plyngent.cli.display import set_markdown_enabled, set_verbose_tool_results
@@ -107,6 +115,44 @@ class ReplState:
click.secho("yolo=off (once expired)", fg="bright_black", err=True)
def _bind_todo_tools(self) -> None:
"""Point module-level todo tools at this session stack + persist hook."""
import asyncio
def on_change() -> None:
if self.session_id is None:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
task = loop.create_task(self.memory.update_session_todo_stack(self.session_id, self.todo_stack.to_raw()))
# Keep a strong ref until done so the task is not GC'd mid-flight.
self._todo_persist_tasks.add(task)
task.add_done_callback(self._todo_persist_tasks.discard)
set_todo_stack(self.todo_stack, on_change=on_change)
async def persist_todo_stack(self) -> None:
"""Write the in-memory todo stack to the active session row."""
if self.session_id is None:
return
_ = await self.memory.update_session_todo_stack(self.session_id, self.todo_stack.to_raw())
async def load_todo_stack(self) -> None:
"""Load todo stack from the active session (empty if none)."""
if self.session_id is None:
self.todo_stack = TodoStack()
self._bind_todo_tools()
if hasattr(self, "agent"):
self.agent.todo_stack = self.todo_stack
return
raw = await self.memory.get_session_todo_stack(self.session_id)
self.todo_stack = TodoStack.from_raw(raw)
self._bind_todo_tools()
if hasattr(self, "agent"):
self.agent.todo_stack = self.todo_stack
def _tool_registry(self) -> ToolRegistry | None:
if not self.tools_enabled:
return None
@@ -140,18 +186,23 @@ class ReplState:
max_tool_result_chars=agent_cfg.max_tool_result_chars,
parallel_tools=agent_cfg.parallel_tools,
max_context_tokens=agent_cfg.max_context_tokens,
todo_stack=self.todo_stack,
todo_nag_strategy=agent_cfg.todo_nag_strategy,
)
def rebuild_client(self) -> None:
"""Recreate client and agent after provider/model/tools change."""
messages = list(self.agent.messages)
persist_from = self.agent.persist_from
# Preserve live stream toggle if agent already exists.
if hasattr(self, "agent"):
self.stream_enabled = self.agent.stream
self.client = cast("ChatClient", cast("object", create_client(self.provider)))
self.agent = self._make_agent()
self.agent.messages = messages
# Restore history without re-marking already-stored messages as dirty.
self.agent.replace_messages(messages, persist_from=persist_from)
self.sync_display_flags()
self._bind_todo_tools()
# Drop remote catalog when provider identity/url changed (not on model-only switch).
if self._remote_models_key is not None and self._remote_models_key != self._models_cache_key():
self.invalidate_remote_models()
@@ -174,6 +225,45 @@ class ReplState:
self._remote_models_key = self._models_cache_key()
self._remote_models_error = None
def schedule_remote_models_warm(self) -> None:
"""Fire-and-forget ``GET /models`` so Tab complete warms without blocking ready."""
import asyncio
async def _warm() -> None:
try:
_ = await self.ensure_remote_models(refresh=False)
except RuntimeError, TypeError, OSError, ValueError, TimeoutError:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
task = loop.create_task(_warm(), name="plyngent-warm-remote-models")
# Keep a strong ref until done (same pattern as todo persist tasks).
self._todo_persist_tasks.add(task)
def _done(t: object) -> None:
_ = self._todo_persist_tasks.discard(t)
task.add_done_callback(_done)
def remember_session_ids(self, ids: Sequence[int]) -> None:
"""Cache session ids for Tab completion (``/resume`` / ``/delete``)."""
self._session_id_cache = [int(i) for i in ids]
def session_ids_for_complete(self) -> list[str]:
"""String session ids for completers (cache + current session if any)."""
seen: set[int] = set()
out: list[str] = []
for sid in self._session_id_cache:
if sid not in seen:
seen.add(sid)
out.append(str(sid))
if self.session_id is not None and self.session_id not in seen:
out.insert(0, str(self.session_id))
return out
def cached_remote_models(self) -> list[str] | None:
"""Return cached remote ids if still valid for the current provider."""
if self._remote_models is None or self._remote_models_fetched_at is None:
@@ -206,7 +296,7 @@ class ReplState:
raise TypeError(msg)
try:
ids = await fetch_remote_model_ids(self.client)
except (RuntimeError, TypeError, OSError, ValueError) as exc:
except (RuntimeError, TypeError, OSError, ValueError, TimeoutError) as exc:
self._remote_models_error = str(exc)
raise
self._remote_models = list(ids)
@@ -216,11 +306,15 @@ class ReplState:
return list(ids)
async def merged_model_choices(self, *, refresh: bool = False) -> list[str]:
"""Config plus remote catalog; remote fetch best-effort when refresh/missing."""
"""Config plus remote catalog; remote fetch best-effort when refresh/missing.
Network / timeout / cancel failures fall back to cache or config-only ids
so ``/provider`` switches never hang or fail solely on GET /models.
"""
remote: list[str] | None
try:
remote = await self.ensure_remote_models(refresh=refresh)
except RuntimeError, TypeError, OSError, ValueError:
except RuntimeError, TypeError, OSError, ValueError, TimeoutError:
remote = self.cached_remote_models()
return model_choices_for_provider(self.provider, remote_ids=remote)
@@ -290,6 +384,28 @@ class ReplState:
model=self.model,
)
def persist_models_to_config(
self,
*,
mode: Literal["current", "catalog"],
catalog_ids: Sequence[str] | None = None,
) -> Path:
"""Merge model id(s) into TOML for the current provider and write disk.
*mode* ``current``: ensure :attr:`model` is in the provider catalog.
*mode* ``catalog``: union *catalog_ids* (or empty) into the catalog.
Returns the config path written. Raises ``OSError`` / ``ValueError`` /
``KeyError`` on failure.
"""
if mode == "current":
self.provider = self.config.ensure_model(self.provider_name, self.model)
else:
ids = list(catalog_ids) if catalog_ids is not None else []
self.provider = self.config.merge_models(self.provider_name, ids)
self.config.write()
return self.config.path
def _try_set_provider(self, pname: str) -> bool:
import click
@@ -353,7 +469,11 @@ class ReplState:
model=self.model,
)
self.session_id = session.sid
if session.sid not in self._session_id_cache:
self._session_id_cache.insert(0, session.sid)
self.todo_stack = TodoStack()
self.agent = self._make_agent()
self._bind_todo_tools()
async def rename_current_session(self, name: str) -> SessionRow:
if self.session_id is None:
@@ -404,11 +524,14 @@ class ReplState:
raise ValueError(msg) from exc
self.session_id = session_id
if session_id not in self._session_id_cache:
self._session_id_cache.insert(0, session_id)
if self.apply_session_llm(row):
self.rebuild_client()
else:
self.agent = self._make_agent()
await self.agent.load_history()
await self.load_todo_stack()
async def resume_latest_or_new(self, name: str = "chat") -> str:
"""Resume most recently updated session for this workspace, or create one."""
@@ -423,6 +546,7 @@ class ReplState:
else:
self.agent = self._make_agent()
await self.agent.load_history()
await self.load_todo_stack()
_ = await self.memory.touch_session(latest.sid)
return "resume"
@@ -430,6 +554,7 @@ class ReplState:
"""Soft-compact + model-summarize current history into a new workspace session.
Returns ``(old_session_id, new_session_id, summary)``.
Todo stack is carried into the new session.
"""
from plyngent.agent.compact import build_compacted_seed_messages, summarize_messages
@@ -461,6 +586,7 @@ class ReplState:
system_prompt=self.config.agent_config.compact_system_prompt or None,
user_prefix=self.config.agent_config.compact_user_prefix or None,
)
carried_todos = self.todo_stack.to_raw()
session_name = name or f"compact-from-{old_id}"
await self.new_session(name=session_name)
new_id = self.session_id
@@ -474,7 +600,17 @@ class ReplState:
source_session_id=old_id,
seed_text=self.config.agent_config.compact_seed_text or None,
)
self.agent.messages = list(seed)
for message in seed:
_ = await self.memory.append_message(new_id, message)
# Reload from DB so RAM matches stored rows and the persist cursor is correct
# (assigning messages alone left _persist_from at 0 and broke later checkpoints).
await self.agent.load_history()
if not self.agent.messages:
msg = f"compact session {new_id} has no messages after seed"
raise RuntimeError(msg)
# Carry open/closed todos so multi-step work survives compact.
self.todo_stack = TodoStack.from_raw(carried_todos)
self._bind_todo_tools()
self.agent.todo_stack = self.todo_stack
await self.persist_todo_stack()
return old_id, new_id, summary
+11 -2
View File
@@ -4,10 +4,15 @@ from msgspec import Struct, field
class DatabaseConfig(Struct, omit_defaults=True):
"""Database connection configuration."""
"""Database connection configuration.
``url`` is ``None`` when unset. The CLI fills a durable user-data
``chat.db`` only for unset/empty url. Explicit ``":memory:"`` is a true
in-memory SQLite (no rewrite).
"""
implementation: str = "sqlite"
url: str = ":memory:"
url: str | None = None
username: str | None = None
password: str | None = None
@@ -22,6 +27,10 @@ class AgentConfig(Struct, omit_defaults=True):
path_denylist: list[str] = field(default_factory=list)
max_context_tokens: int = 200_000
# How to inject todo stack nags into model context (see agent/todo_nag.py).
# developer | user | synthetic_tool | none (legacy "system" → developer)
todo_nag_strategy: str = "developer"
# Compact / summarisation prompts (empty = use built-in defaults).
compact_system_prompt: str = ""
compact_user_prefix: str = ""
+53
View File
@@ -200,6 +200,59 @@ class ConfigStore:
_ = self._bad_providers.pop(name, None)
return promoted
def _take_provider(self, name: str) -> Provider:
"""Return a ready or recoverable provider, promoting recoverable into ready map."""
if name in self._providers:
return self._providers[name]
if name in self._recoverable_providers:
provider = self._recoverable_providers.pop(name)
self._providers[name] = provider
_ = self._bad_providers.pop(name, None)
return provider
msg = f"unknown provider {name!r}"
raise KeyError(msg)
def ensure_model(self, name: str, model_id: str) -> Provider:
"""Ensure *model_id* exists under ``providers[name].models`` (in memory).
Does not write the TOML file unless the caller invokes :meth:`write`.
"""
mid = model_id.strip()
if not mid:
msg = "model id must not be empty"
raise ValueError(msg)
provider = self._take_provider(name)
if mid in provider.models:
return provider
models = dict(provider.models)
models[mid] = ModelConfig()
updated = msgspec.structs.replace(provider, models=models)
self._providers[name] = updated
return updated
def merge_models(self, name: str, model_ids: Sequence[str]) -> Provider:
"""Union *model_ids* into the provider catalog (keep existing configs).
Does not write the TOML file unless the caller invokes :meth:`write`.
"""
provider = self._take_provider(name)
models = dict(provider.models)
added = False
for raw in model_ids:
mid = raw.strip() if raw else ""
if not mid or mid in models:
continue
models[mid] = ModelConfig()
added = True
if not added and models:
return provider
if not models:
msg = f"cannot merge models for provider {name!r}: no model ids"
raise ValueError(msg)
updated = msgspec.structs.replace(provider, models=models)
self._providers[name] = updated
return updated
# -- persistence --
def write(self) -> None:
+1 -1
View File
@@ -20,7 +20,7 @@ def build_async_url(config: DatabaseConfig) -> str:
raise UnsupportedDatabaseError(msg)
url = config.url
if url in {":memory:", ""}:
if url is None or url in {":memory:", ""}:
return "sqlite+aiosqlite:///:memory:"
if url.startswith("sqlite+aiosqlite://"):
return url
+2
View File
@@ -32,6 +32,8 @@ class Session(PlyngentBase):
# Last selected provider/model for this session (config provider key + model id).
provider_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
model: Mapped[str | None] = mapped_column(String(256), nullable=True)
# Todo/task stack JSON for multi-step sub-tasks (optional).
todo_stack: Mapped[dict[str, object] | None] = mapped_column(JSON(), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+38 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Self
from typing import TYPE_CHECKING, Self, cast
import msgspec
from sqlalchemy import delete, select, text
@@ -68,6 +68,7 @@ class MemoryStore:
_ = await conn.run_sync(PlyngentBase.metadata.create_all)
await conn.run_sync(_migrate_session_workspace)
await conn.run_sync(_migrate_session_llm)
await conn.run_sync(_migrate_session_todo_stack)
async def close(self) -> None:
"""Dispose the underlying engine."""
@@ -208,6 +209,31 @@ class MemoryStore:
await session.refresh(row)
return row
async def get_session_todo_stack(self, sid: int) -> dict[str, object] | None:
"""Return the stored todo stack JSON for *sid*, or None."""
async with self._session_factory() as session:
row = await session.get(Session, sid)
if row is None:
msg = f"session not found: {sid}"
raise ValueError(msg)
raw = row.todo_stack
if raw is None:
return None
return {str(k): v for k, v in cast("dict[object, object]", raw).items()}
async def update_session_todo_stack(self, sid: int, data: dict[str, object] | None) -> Session:
"""Persist todo stack JSON for a session (None clears)."""
async with self._session_factory() as session:
row = await session.get(Session, sid)
if row is None:
msg = f"session not found: {sid}"
raise ValueError(msg)
row.todo_stack = data
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return row
async def rename_session(self, sid: int, name: str) -> Session:
"""Rename a session (max 64 characters, non-empty after strip)."""
cleaned = name.strip()
@@ -308,3 +334,14 @@ def _migrate_session_llm(sync_conn: object) -> None:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN provider_name VARCHAR(128)"))
if "model" not in columns:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN model VARCHAR(256)"))
def _migrate_session_todo_stack(sync_conn: object) -> None:
"""Add ``session.todo_stack`` JSON for the todo/task sub-task stack."""
from sqlalchemy.engine import Connection
if not isinstance(sync_conn, Connection):
return
columns = _session_columns(sync_conn)
if "todo_stack" not in columns:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN todo_stack JSON"))
+35
View File
@@ -55,6 +55,10 @@ class PromptBackend(Protocol):
completions: Sequence[str] | None = None,
) -> str: ...
def read_secret_line(self, prompt: str) -> str:
"""Read a line without echo (passwords). Cancel raises NonInteractiveError."""
...
def confirm(self, prompt: str, *, default: bool = False) -> bool: ...
def echo(self, message: str = "", *, err: bool = False) -> None: ...
@@ -117,6 +121,16 @@ class ClickPromptBackend:
return default
return raw
def read_secret_line(self, prompt: str) -> str:
import getpass
try:
display = f"{prompt}: " if not prompt.endswith(": ") else prompt
return getpass.getpass(display)
except (KeyboardInterrupt, EOFError) as exc:
msg = "prompt cancelled"
raise NonInteractiveError(msg) from exc
def confirm(self, prompt: str, *, default: bool = False) -> bool:
try:
return bool(click.confirm(prompt, default=default))
@@ -150,6 +164,10 @@ class NonInteractiveBackend:
msg = f"non-interactive: cannot prompt for {prompt!r}"
raise NonInteractiveError(msg)
def read_secret_line(self, prompt: str) -> str:
msg = f"non-interactive: cannot prompt for secret {prompt!r}"
raise NonInteractiveError(msg)
def confirm(self, prompt: str, *, default: bool = False) -> bool:
del prompt
return default
@@ -255,6 +273,19 @@ def ask(
return backend.read_line("Answer", default=default, completions=completions).strip()
def ask_secret(prompt: str) -> str:
"""Prompt for a secret line (no echo). Never use for values that return to the model.
Cancel (Ctrl+C / EOF) raises :class:`NonInteractiveError`.
"""
backend = get_prompt_backend()
if not backend.is_interactive():
msg = f"non-interactive: cannot prompt for secret {prompt!r}"
raise NonInteractiveError(msg)
backend.secho(prompt, fg="yellow")
return backend.read_secret_line("Secret")
def choose(
prompt: str,
options: Sequence[str] | Sequence[ChoiceOption],
@@ -363,6 +394,10 @@ async def ask_async(prompt: str, *, default: str | None = None) -> str:
return await run_prompt_async(ask, prompt, default=default)
async def ask_secret_async(prompt: str) -> str:
return await run_prompt_async(ask_secret, prompt)
async def choose_async(
prompt: str,
options: Sequence[str] | Sequence[ChoiceOption],
+25 -1
View File
@@ -16,11 +16,24 @@ from .file import read_file as read_file
from .file import tree as tree
from .file import write_file as write_file
from .process import PROCESS_TOOLS as PROCESS_TOOLS
from .process import ask_into_pty as ask_into_pty
from .process import close_pty as close_pty
from .process import open_pty as open_pty
from .process import read_pty as read_pty
from .process import run_command as run_command
from .process import run_command_batch as run_command_batch
from .process import write_pty as write_pty
from .process import write_pty_keys as write_pty_keys
from .temp_workspace import cleanup_temporary_workspaces as cleanup_temporary_workspaces
from .temp_workspace import new_temporary_workspace as new_temporary_workspace
from .todo import TODO_TOOLS as TODO_TOOLS
from .todo import get_todo_stack as get_todo_stack
from .todo import set_todo_stack as set_todo_stack
from .todo import todo_clear as todo_clear
from .todo import todo_list as todo_list
from .todo import todo_pop as todo_pop
from .todo import todo_push as todo_push
from .todo import todo_update as todo_update
from .vcs import VCS_TOOLS as VCS_TOOLS
from .vcs import vcs_branch as vcs_branch
from .vcs import vcs_diff as vcs_diff
@@ -30,15 +43,26 @@ from .vcs import vcs_status as vcs_status
from .workspace import (
DEFAULT_COMMAND_DENYLIST as DEFAULT_COMMAND_DENYLIST,
)
from .workspace import DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS as DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS
from .workspace import WorkspaceError as WorkspaceError
from .workspace import add_workspace_allowlist as add_workspace_allowlist
from .workspace import check_command_allowed as check_command_allowed
from .workspace import clear_policy_allowed_commands as clear_policy_allowed_commands
from .workspace import clear_workspace_allowlist as clear_workspace_allowlist
from .workspace import clear_workspace_root as clear_workspace_root
from .workspace import get_command_denylist as get_command_denylist
from .workspace import get_path_denylist as get_path_denylist
from .workspace import get_policy_confirm_timeout as get_policy_confirm_timeout
from .workspace import get_workspace_root as get_workspace_root
from .workspace import grant_policy_command as grant_policy_command
from .workspace import list_workspace_allowlist as list_workspace_allowlist
from .workspace import pop_owned_temporary_workspaces as pop_owned_temporary_workspaces
from .workspace import remove_workspace_allowlist as remove_workspace_allowlist
from .workspace import resolve_path as resolve_path
from .workspace import set_command_denylist as set_command_denylist
from .workspace import set_path_denylist as set_path_denylist
from .workspace import set_policy_confirm_hook as set_policy_confirm_hook
from .workspace import set_policy_confirm_timeout as set_policy_confirm_timeout
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]
+234 -23
View File
@@ -1,46 +1,257 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import json
import shlex
from typing import TYPE_CHECKING, cast
from plyngent.tools.workspace import WorkspaceError, resolve_path
if TYPE_CHECKING:
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
_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 _indent_block(text: str, *, prefix: str = " ") -> str:
"""Indent every line of *text* (for multi-line -c bodies in confirm prompts)."""
if not text:
return prefix
return "\n".join(prefix + line if line else prefix.rstrip() for line in text.splitlines())
def _format_argv_for_confirm(argv: Sequence[str], *, code: str | None) -> str:
"""One-line argv summary; replace -c payload with ``$(command)`` when present."""
if code is None:
return shlex.join(list(argv))
parts: list[str] = []
skip_next = False
for part in argv:
if skip_next:
skip_next = False
continue
if part == "-c":
parts.append("-c")
parts.append("$(command)")
skip_next = True
continue
parts.append(part)
return shlex.join(parts)
def _shell_or_dash_c_reason(argv: Sequence[str], *, via: str) -> str | None:
"""Confirm interactive shells and ``*-c`` one-liners so the user can inspect argv.
Multi-line reason (shown inside the CLI confirm box). ``via`` is a short
label such as ``run_command`` or ``open_pty``.
For ``-c`` scripts, argv shows ``$(command)`` instead of inlining the body;
the full script is printed below untruncated, with every line indented.
"""
if not argv:
return None
base = _basename(argv[0])
code = _find_dash_c_code(argv)
display = _format_argv_for_confirm(argv, code=code)
if code is not None:
body = _indent_block(code, prefix=" ")
return f"{via}: {base} -c (review code before allow)\n argv: {display}\n command:\n{body}"
if base in _SHELL_BASENAMES and len(argv) == 1:
return f"{via}: interactive {base!r} (review before allow)\n argv: {display}"
if base in _SHELL_BASENAMES and "-c" not in argv[1:]:
return f"{via}: shell/runtime {base!r} without -c (review before allow)\n argv: {display}"
return None
def _write_file_reason(args: Mapping[str, object]) -> str | None:
"""Confirm only when write_file would replace an existing file."""
path = args.get("path")
if not isinstance(path, str) or not path:
return None
try:
target = resolve_path(path)
except WorkspaceError:
# Path policy will fail later; no soft-confirm without a resolved target.
return None
if target.exists() or target.is_symlink():
return f"overwrite existing file {path!r}"
if target.is_file():
return f"overwrite existing file {path!r} ({target})"
# New file or directory path: no total-overwrite risk for soft-confirm.
return None
def classify_danger(name: str, args: Mapping[str, object]) -> str | None:
def _copy_path_reason(args: Mapping[str, object]) -> str | None:
"""Confirm copy only when it would replace an existing destination path."""
src = args.get("src")
dst = args.get("dst")
overwrite = bool(args.get("overwrite", False))
if not overwrite:
return None
if not isinstance(dst, str) or not dst:
return f"copy {src!r}{dst!r} (overwrite)"
try:
target = resolve_path(dst)
except WorkspaceError:
return None
if target.exists():
return f"copy {src!r} → overwrite existing {dst!r} ({target})"
return None
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 _batch_step_argv(item: object) -> list[str] | None:
if not isinstance(item, dict):
return None
step = cast("dict[str, object]", item)
command = step.get("command")
if not isinstance(command, list) or not command:
return None
argv: list[str] = []
for part in cast("list[object]", command):
if not isinstance(part, str):
return None
argv.append(part)
return argv or None
def _run_command_batch_reason(args: Mapping[str, object]) -> str | None:
"""One confirm for the whole batch if any step is shell/REPL/-c."""
raw = args.get("commands")
if isinstance(raw, str):
try:
loaded: object = json.loads(raw)
except json.JSONDecodeError:
return None
raw = loaded
if not isinstance(raw, list):
return None
risky = [
reason
for index, item in enumerate(cast("list[object]", raw))
if (argv := _batch_step_argv(item)) is not None
and (reason := _shell_or_dash_c_reason(argv, via=f"run_command_batch[{index}]")) is not None
]
if not risky:
return None
header = f"run_command_batch: {len(risky)} risky step(s) (review before allow)"
return header + "\n" + "\n".join(risky)
def _open_pty_reason(args: Mapping[str, object]) -> str | None:
argv = _as_argv(args)
if argv is None:
return None
return _shell_or_dash_c_reason(argv, via="open_pty")
def classify_danger(name: str, args: Mapping[str, object]) -> str | None: # noqa: PLR0911
"""Return a short reason if ``name``/``args`` need user confirm, else ``None``.
Hard denylists (paths/commands) still raise independently. This only covers
soft confirms for mutating tools that policy otherwise allows.
soft confirms for mutating tools and risky shell/REPL launches
(interactive shells and ``python -c`` / ``bash -c`` one-liners).
"""
reasons: dict[str, str | None] = {
"delete_path": (
f"delete path {args.get('path', '')!r} recursively"
if bool(args.get("recursive", False))
else f"delete path {args.get('path', '')!r}"
),
"move_path": f"move {args.get('src', '')!r} -> {args.get('dst', '')!r}",
"copy_path": (
f"copy with overwrite {args.get('src', '')!r} -> {args.get('dst', '')!r}"
if bool(args.get("overwrite", False))
else None
),
"write_file": _write_file_reason(args),
}
if name not in reasons:
return None
return reasons[name]
if name == "delete_path":
return _delete_path_reason(args)
if name == "move_path":
return _move_path_reason(args)
if name == "copy_path":
return _copy_path_reason(args)
if name == "write_file":
return _write_file_reason(args)
if name == "run_command":
return _run_command_reason(args)
if name == "run_command_batch":
return _run_command_batch_reason(args)
if name == "open_pty":
return _open_pty_reason(args)
return None
+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_replace import edit_replace as edit_replace
from .fs_ops import copy_path as copy_path
@@ -22,4 +24,5 @@ FILE_TOOLS = [
copy_path,
move_path,
delete_path,
new_temporary_workspace,
]
+42 -5
View File
@@ -4,17 +4,54 @@ from plyngent.agent import tool
from plyngent.tools.workspace import resolve_path
def _count_non_overlapping(text: str, needle: str) -> int:
"""Count left-to-right non-overlapping matches (same as ``str.replace``)."""
if not needle:
return 0
n = 0
start = 0
while True:
index = text.find(needle, start)
if index < 0:
return n
n += 1
start = index + len(needle)
def _success_message(path: str, *, replaced: int, found: int) -> str:
remaining = found - replaced
unit = "occurrence" if replaced == 1 else "occurrences"
if remaining == 0:
if found == 1:
return f"replaced 1 occurrence in {path}"
return f"replaced {replaced} {unit} in {path} (all {found} matches)"
return (
f"replaced {replaced} {unit} in {path} "
f"({replaced} of {found} matches; {remaining} remain — "
f"raise max_replaces or use a more specific old_string)"
)
@tool
def edit_replace(path: str, old_string: str, new_string: str) -> str:
"""Replace the first occurrence of ``old_string`` with ``new_string`` in a file."""
def edit_replace(path: str, old_string: str, new_string: str, max_replaces: int = 1) -> str:
"""Replace occurrences of ``old_string`` with ``new_string`` in a file.
Replaces left-to-right, non-overlapping. Default ``max_replaces=1`` (first match
only). Raise ``max_replaces`` to change multiple identical hits; use a more
specific ``old_string`` when you need a particular occurrence.
"""
if not old_string:
return "error: old_string must not be empty"
if max_replaces < 1:
return "error: max_replaces must be >= 1"
target = resolve_path(path)
if not target.is_file():
return f"error: not a file: {path}"
text = target.read_text(encoding="utf-8", errors="replace")
if old_string not in text:
found = _count_non_overlapping(text, old_string)
if found == 0:
return "error: old_string not found in file"
updated = text.replace(old_string, new_string, 1)
n = min(max_replaces, found)
updated = text.replace(old_string, new_string, n)
_ = target.write_text(updated, encoding="utf-8")
return f"replaced first occurrence in {path}"
return _success_message(path, replaced=n, found=found)
+26 -2
View File
@@ -3,12 +3,33 @@ from __future__ import annotations
from plyngent.agent import tool
from plyngent.tools.workspace import resolve_path
_LINENO_WIDTH = 6
def _format_with_lineno(lines: list[str], *, start_lineno: int) -> str:
"""Prefix each line with a 1-based absolute line number (``edit_lineno`` style)."""
out: list[str] = []
for index, line in enumerate(lines):
lineno = start_lineno + index
# Strip keepends for the body; re-add a single newline after the prefix.
body = line.rstrip("\r\n")
out.append(f"{lineno:>{_LINENO_WIDTH}}|{body}\n")
return "".join(out)
@tool
def read_file(path: str, *, offset: int = 0, limit: int | None = None) -> str:
def read_file(
path: str,
*,
offset: int = 0,
limit: int | None = None,
with_lineno: bool = False,
) -> str:
"""Read a text file under the workspace.
``offset`` is 0-based line start; ``limit`` is max lines (None = rest of file).
When ``with_lineno`` is true, each line is prefixed with its 1-based file line
number (`` N|…``), matching ``edit_lineno`` numbering.
"""
target = resolve_path(path)
if not target.is_file():
@@ -21,4 +42,7 @@ def read_file(path: str, *, offset: int = 0, limit: int | None = None) -> str:
end = len(lines) if limit is None else min(len(lines), start + limit)
if start >= len(lines):
return ""
return "".join(lines[start:end])
slice_lines = lines[start:end]
if with_lineno:
return _format_with_lineno(slice_lines, start_lineno=start + 1)
return "".join(slice_lines)
+71 -12
View File
@@ -4,9 +4,10 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING
from plyngent.agent import tool
from plyngent.tools.workspace import WorkspaceError, resolve_path
from plyngent.tools.workspace import WorkspaceError, get_path_denylist, resolve_path
if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path
DEFAULT_MAX_DEPTH = 4
@@ -26,21 +27,58 @@ VCS_DIR_NAMES: frozenset[str] = frozenset(
}
)
# Extra noise dirs skipped by default (in addition to VCS / optional hidden).
# Pass skip_dirs=[] to disable this list (VCS still always skipped).
DEFAULT_NOISE_DIR_NAMES: frozenset[str] = frozenset(
{
"node_modules",
"__pycache__",
".venv",
"venv",
"dist",
"build",
"target",
".tox",
".mypy_cache",
".ruff_cache",
".pytest_cache",
"coverage",
".next",
".nuxt",
".turbo",
".cache",
"eggs",
".eggs",
"htmlcov",
}
)
@dataclass(frozen=True, slots=True)
class _TreeLimits:
max_depth: int
max_entries: int
skip_hidden_dirs: bool
skip_basenames: frozenset[str]
apply_path_denylist: bool
def _skip_directory(name: str, *, skip_hidden_dirs: bool) -> bool:
if name in VCS_DIR_NAMES:
def _skip_directory(name: str, *, limits: _TreeLimits) -> bool:
if name in VCS_DIR_NAMES or name in limits.skip_basenames:
return True
return bool(skip_hidden_dirs and name.startswith("."))
return bool(limits.skip_hidden_dirs and name.startswith("."))
def _list_children(directory: Path, *, skip_hidden_dirs: bool) -> list[Path] | str:
def _path_denied(path: Path) -> bool:
"""True when resolved path matches a path_denylist substring."""
denylist = get_path_denylist()
if not denylist:
return False
resolved_str = str(path).replace("\\", "/")
return any(pattern and pattern.replace("\\", "/") in resolved_str for pattern in denylist)
def _list_children(directory: Path, *, limits: _TreeLimits) -> list[Path] | str:
try:
children = list(directory.iterdir())
except OSError as exc:
@@ -51,7 +89,9 @@ def _list_children(directory: Path, *, skip_hidden_dirs: bool) -> list[Path] | s
is_dir = child.is_dir()
except OSError:
continue
if is_dir and _skip_directory(child.name, skip_hidden_dirs=skip_hidden_dirs):
if is_dir and _skip_directory(child.name, limits=limits):
continue
if limits.apply_path_denylist and _path_denied(child):
continue
visible.append(child)
# Directories first, then files; alphabetical within each group.
@@ -70,7 +110,7 @@ def _render_tree(
if depth >= limits.max_depth:
return
children = _list_children(directory, skip_hidden_dirs=limits.skip_hidden_dirs)
children = _list_children(directory, limits=limits)
if isinstance(children, str):
lines.append(f"{prefix}{children}")
return
@@ -104,6 +144,13 @@ def _render_tree(
lines.append(f"{prefix}└── … ({more} more entries not shown)")
def _resolve_skip_basenames(skip_dirs: Sequence[str] | None) -> frozenset[str]:
"""None → default noise set; explicit list (including empty) replaces defaults."""
if skip_dirs is None:
return DEFAULT_NOISE_DIR_NAMES
return frozenset(name for name in skip_dirs if name)
@tool
def tree(
path: str = ".",
@@ -111,13 +158,22 @@ def tree(
max_depth: int = DEFAULT_MAX_DEPTH,
max_entries: int = DEFAULT_MAX_ENTRIES,
skip_hidden_dirs: bool = True,
skip_dirs: list[str] | None = None,
apply_path_denylist: bool = True,
) -> str:
"""Show a directory tree under the workspace.
Always skips VCS metadata directories (``.git``, ``.hg``, ``.svn``, …).
By default also skips common noise dirs (``node_modules``, ``__pycache__``,
``.venv``, ``dist``, …). Pass ``skip_dirs=[]`` to disable the noise list
(VCS still skipped). Pass an explicit list to replace the default noise set.
By default skips other dot-directories (not hidden files). Use
``skip_hidden_dirs=false`` to include them.
``apply_path_denylist`` (default true) hides entries whose full path matches
the agent ``path_denylist`` policy.
``max_depth`` limits how deep directories are expanded (1 = origin + children).
``max_entries`` caps how many entries are listed per directory.
"""
@@ -135,15 +191,18 @@ def tree(
root_label = path.rstrip("/\\") or "."
lines = [f"{root_label}/"]
limits = _TreeLimits(
max_depth=max_depth,
max_entries=max_entries,
skip_hidden_dirs=skip_hidden_dirs,
skip_basenames=_resolve_skip_basenames(skip_dirs),
apply_path_denylist=apply_path_denylist,
)
_render_tree(
origin,
prefix="",
depth=0,
limits=_TreeLimits(
max_depth=max_depth,
max_entries=max_entries,
skip_hidden_dirs=skip_hidden_dirs,
),
limits=limits,
lines=lines,
)
return "\n".join(lines)
+15 -1
View File
@@ -1,7 +1,21 @@
from .ask_into_pty import ask_into_pty as ask_into_pty
from .close_pty import close_pty as close_pty
from .open_pty import open_pty as open_pty
from .pty_terminal import decode_write_data as decode_write_data
from .pty_terminal import sanitize_pty_output_for_tool as sanitize_pty_output_for_tool
from .read_pty import read_pty as read_pty
from .run_command import run_command as run_command
from .run_command_batch import run_command_batch as run_command_batch
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,
run_command_batch,
open_pty,
read_pty,
write_pty,
write_pty_keys,
ask_into_pty,
close_pty,
]
@@ -0,0 +1,78 @@
from __future__ import annotations
from typing import Literal
from plyngent.agent import tool
from plyngent.prompting import NonInteractiveError, ask_async, ask_secret_async
from plyngent.tools.workspace import WorkspaceError
from .pty_session import PtyManager
from .write_pty import write_pty_payload
type _PromptResult = tuple[Literal["ok"], str] | tuple[Literal["err"], str]
def _status_without_payload(
status: str,
*,
secret: bool,
submit: bool,
) -> str:
lines = [line for line in status.splitlines() if not line.startswith("wrote=")]
lines.append("wrote=true")
lines.append(f"secret={'true' if secret else 'false'}")
lines.append(f"submit={'true' if submit else 'false'}")
lines.append("source=human")
return "\n".join(lines)
async def _prompt_answer(label: str, *, secret: bool) -> _PromptResult:
try:
answer = await ask_secret_async(label) if secret else await ask_async(label)
except NonInteractiveError as exc:
return ("err", f"error: {exc}")
except KeyboardInterrupt, EOFError:
return ("err", "error: prompt cancelled")
if answer == "":
return ("err", "error: empty input cancelled (nothing written to PTY)")
return ("ok", answer)
@tool
async def ask_into_pty(
session_id: int,
message: str,
*,
secret: bool = False,
submit: bool = True,
) -> str:
"""Prompt the **human** and write their answer into a PTY (local only).
Use for interactive prompts (e.g. sudo/ssh password). The answer is written
to the PTY master on this machine and is **never** included in the tool
result (so it is not sent to external model APIs).
``message`` is shown to the human (what to enter), not the secret itself.
``secret=true`` uses no-echo input. ``submit=true`` (default) appends a
newline after the answer. Empty input and Ctrl+C cancel without writing.
"""
label = message.strip() or ("Secret" if secret else "Input")
try:
# Validate session before blocking the human.
_ = PtyManager.refresh(session_id)
except WorkspaceError as exc:
return f"error: {exc}"
kind, value = await _prompt_answer(label, secret=secret)
if kind == "err":
return value
payload = value + ("\n" if submit else "")
try:
status = write_pty_payload(session_id, payload)
except WorkspaceError as exc:
return f"error: {exc}"
except OSError as exc:
return f"error: failed to write PTY: {exc}"
return _status_without_payload(status, secret=secret, submit=submit)
+143
View File
@@ -0,0 +1,143 @@
from __future__ import annotations
import asyncio
import os
import shlex
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
from plyngent.tools.workspace import (
WorkspaceError,
check_command_allowed,
get_workspace_root,
resolve_path,
)
DEFAULT_TIMEOUT_SECONDS = 30
DEFAULT_MAX_OUTPUT_CHARS = 32_000
DEFAULT_MAX_BATCH_STEPS = 20
def truncate_output(text: str, label: str, max_chars: int = DEFAULT_MAX_OUTPUT_CHARS) -> str:
if len(text) <= max_chars:
return text
return text[:max_chars] + f"\n...[{label} truncated]"
def format_command_result(
*,
returncode: int | None,
workdir_display: str,
command: list[str],
stdout: str,
stderr: str,
timed_out: bool = False,
) -> str:
parts = [
f"exit_code={returncode if returncode is not None else ''}",
f"timed_out={'true' if timed_out else 'false'}",
f"cwd={workdir_display}",
f"cmd={shlex.join(command)}",
"--- stdout ---",
truncate_output(stdout, "stdout"),
"--- stderr ---",
truncate_output(stderr, "stderr"),
]
return "\n".join(parts)
def resolve_workdir(cwd: str) -> tuple["Path", str]:
"""Return (absolute workdir, display path relative to workspace when possible)."""
workdir = resolve_path(cwd)
if not workdir.is_dir():
msg = f"not a directory: {cwd}"
raise WorkspaceError(msg)
try:
workdir_display = str(workdir.relative_to(get_workspace_root()))
except ValueError:
workdir_display = str(workdir)
return workdir, workdir_display
@dataclass
class CommandStepResult:
command: list[str]
cwd_display: str
exit_code: int | None
timed_out: bool
stdout: str
stderr: str
mix_stderr: bool
captured: str # piped to the next step when provider sets pipe_out
async def execute_argv(
command: list[str],
*,
cwd: str = ".",
env: dict[str, str] | None = None,
stdin: str | None = None,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
mix_stderr: bool = False,
) -> CommandStepResult:
"""Run one argv command; no shell. Used by run_command and run_command_batch."""
if not command:
msg = "command argv must not be empty"
raise WorkspaceError(msg)
check_command_allowed(command)
workdir, workdir_display = resolve_workdir(cwd)
stdin_data = None if stdin is None else stdin.encode()
run_env: dict[str, str] | None = None
if env is not None:
run_env = {str(k): str(v) for k, v in {**os.environ, **env}.items()}
try:
stderr_arg = asyncio.subprocess.STDOUT if mix_stderr else asyncio.subprocess.PIPE
proc = await asyncio.create_subprocess_exec(
*command,
cwd=str(workdir),
env=run_env,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=stderr_arg,
)
except FileNotFoundError:
msg = f"executable not found: {command[0]!r}"
raise WorkspaceError(msg) from None
except OSError as exc:
msg = f"failed to start command: {exc}"
raise WorkspaceError(msg) from exc
timed_out = False
try:
stdout_b, stderr_b = await asyncio.wait_for(
proc.communicate(input=stdin_data),
timeout=timeout_seconds,
)
except TimeoutError:
timed_out = True
proc.kill()
stdout_b, stderr_b = await proc.communicate()
stdout = (stdout_b or b"").decode(errors="replace")
if mix_stderr:
stderr = ""
captured = stdout
else:
stderr = (stderr_b or b"").decode(errors="replace")
captured = stdout
return CommandStepResult(
command=list(command),
cwd_display=workdir_display,
exit_code=proc.returncode,
timed_out=timed_out,
stdout=stdout,
stderr=stderr,
mix_stderr=mix_stderr,
captured=captured,
)
+30
View File
@@ -258,6 +258,34 @@ else:
raise OSError(msg)
return _spawn_posix(command, cwd=cwd)
def _claim_controlling_tty(slave_fd: int) -> None:
"""Make *slave_fd* the controlling terminal (session leader must call this).
Best-effort: required for programs like ``sudo`` that open ``/dev/tty``
and refuse a non-controlling PTY. Failures are ignored so plain tools
still run if the ioctl is unavailable.
"""
import fcntl
import termios
# Linux: arg 0 = become controlling tty of this session.
tio_csctty = getattr(termios, "TIOCSCTTY", None)
if tio_csctty is not None:
with contextlib.suppress(OSError):
_ = fcntl.ioctl(slave_fd, tio_csctty, 0)
return
# Fallback: reopen slave device path (some BSDs / older kernels).
with contextlib.suppress(OSError):
name = os.ttyname(slave_fd)
reopened = os.open(name, os.O_RDWR)
try:
if reopened != slave_fd:
_ = os.dup2(reopened, slave_fd)
finally:
if reopened != slave_fd:
with contextlib.suppress(OSError):
os.close(reopened)
def _spawn_posix(command: list[str], *, cwd: Path) -> PosixPtyHandle:
import pty
@@ -269,6 +297,8 @@ else:
try:
os.close(master_fd)
_ = os.setsid()
# Claim slave as controlling TTY before wiring stdio (sudo / PAM).
_claim_controlling_tty(slave_fd)
_ = os.dup2(slave_fd, 0)
_ = os.dup2(slave_fd, 1)
_ = os.dup2(slave_fd, _STDERR_FD)
@@ -8,6 +8,7 @@ from threading import Lock
from typing import TYPE_CHECKING, ClassVar
from plyngent.tools.process.pty_backend import PtyHandle, pty_available, spawn_pty
from plyngent.tools.process.pty_terminal import sanitize_pty_output_for_tool
from plyngent.tools.workspace import WorkspaceError, check_command_allowed, resolve_path
if TYPE_CHECKING:
@@ -286,6 +287,8 @@ class PtyManager:
truncated = len(data) > max_bytes
if truncated:
data = data[:max_bytes]
# Escape CSI so tool results echoed to the host TTY cannot reprogram it.
data = sanitize_pty_output_for_tool(data)
budget_exhausted = cls._maybe_raise_budget(session)
return PtyReadResult(
session_id=session_id,
@@ -354,6 +357,8 @@ class PtyManager:
with cls._lock:
_ = cls._sessions.pop(session_id, None)
# No host TTY reset: tool-facing read_pty sanitizes CSI so chat echo
# cannot reprogram the user terminal.
return PtyCloseResult(
session_id=session_id,
closed=True,
@@ -381,6 +386,7 @@ class PtyManager:
@classmethod
def close_all(cls) -> None:
"""Close every open session (no host TTY reset)."""
with cls._lock:
ids = list(cls._sessions.keys())
for session_id in ids:
+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)
+25 -155
View File
@@ -1,133 +1,14 @@
from __future__ import annotations
import asyncio
import os
import shlex
from typing import TYPE_CHECKING
from plyngent.agent import tool
from plyngent.tools.workspace import (
WorkspaceError,
check_command_allowed,
get_workspace_root,
resolve_path,
from plyngent.tools.workspace import WorkspaceError
from .command_exec import (
DEFAULT_TIMEOUT_SECONDS,
execute_argv,
format_command_result,
)
if TYPE_CHECKING:
from pathlib import Path
DEFAULT_TIMEOUT_SECONDS = 30
DEFAULT_MAX_OUTPUT_CHARS = 32_000
def _truncate(text: str, label: str) -> str:
if len(text) <= DEFAULT_MAX_OUTPUT_CHARS:
return text
return text[:DEFAULT_MAX_OUTPUT_CHARS] + f"\n...[{label} truncated]"
def _format_result(
*,
returncode: int | None,
workdir_display: str,
command: list[str],
stdout: str,
stderr: str,
timed_out: bool = False,
) -> str:
parts = [
f"exit_code={'' if returncode is None else returncode}",
f"timed_out={'true' if timed_out else 'false'}",
f"cwd={workdir_display}",
f"cmd={shlex.join(command)}",
]
if stdout:
parts.append("--- stdout ---\n" + stdout.rstrip("\n"))
if stderr:
parts.append("--- stderr ---\n" + stderr.rstrip("\n"))
return "\n".join(parts)
def _validate_env(env: object) -> str | None:
"""Runtime guard for tool-JSON args (may not match static typing at the boundary)."""
if env is None:
return None
if type(env) is not dict:
return "error: env must be an object of string keys and values"
# Tool schema should already enforce dict[str, str]; keep a light check.
return None
def _merge_env(overrides: dict[str, str] | None) -> dict[str, str] | None:
if overrides is None:
return None
return {**os.environ, **overrides}
async def _run_exec(
command: list[str],
*,
workdir: str,
timeout_seconds: float,
stdin_data: bytes | None,
env: dict[str, str] | None,
) -> tuple[int | None, str, str, bool] | str:
"""Return ``(returncode, stdout, stderr, timed_out)`` or an error string."""
stdin = asyncio.subprocess.PIPE if stdin_data is not None else None
try:
proc = await asyncio.create_subprocess_exec(
*command,
cwd=workdir,
env=env,
stdin=stdin,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError:
return f"error: executable not found: {command[0]}"
except OSError as exc:
return f"error: failed to start command: {exc}"
timed_out = False
try:
stdout_b, stderr_b = await asyncio.wait_for(
proc.communicate(input=stdin_data),
timeout=timeout_seconds,
)
except TimeoutError:
timed_out = True
proc.kill()
stdout_b, stderr_b = await proc.communicate()
stdout = _truncate(stdout_b.decode(errors="replace"), "stdout")
stderr = _truncate(stderr_b.decode(errors="replace"), "stderr")
return proc.returncode, stdout, stderr, timed_out
def _validate_run_args(
command: list[str],
*,
cwd: str,
timeout_seconds: float,
env: dict[str, str] | None,
) -> Path | str:
"""Return resolved workdir, or an error string."""
if not command:
return "error: command must not be empty"
try:
check_command_allowed(command)
workdir = resolve_path(cwd)
except WorkspaceError as exc:
return f"error: {exc}"
if not workdir.is_dir():
return f"error: cwd is not a directory: {cwd}"
if timeout_seconds <= 0:
return "error: timeout_seconds must be > 0"
env_error = _validate_env(env)
if env_error is not None:
return env_error
return workdir
@tool
async def run_command(
@@ -138,35 +19,24 @@ async def run_command(
stdin: str | None = None,
env: dict[str, str] | None = None,
) -> str:
"""Run a command without a shell (argv list) under the workspace.
"""Run ``command`` (argv, no shell) under the workspace; capture stdout/stderr."""
try:
result = await execute_argv(
command,
cwd=cwd,
env=env,
stdin=stdin,
timeout_seconds=timeout_seconds,
mix_stderr=False,
)
except WorkspaceError as exc:
return f"error: {exc}"
``cwd`` is relative to or under the workspace root. Optional ``stdin`` is
written to the process stdin. Optional ``env`` overlays process environment
variables (merged with the current environment). Output is truncated.
On timeout the process is killed and any partial stdout/stderr is still
returned with ``timed_out=true``.
"""
workdir = _validate_run_args(command, cwd=cwd, timeout_seconds=timeout_seconds, env=env)
if isinstance(workdir, str):
return workdir
stdin_data = None if stdin is None else stdin.encode()
result = await _run_exec(
command,
workdir=str(workdir),
timeout_seconds=timeout_seconds,
stdin_data=stdin_data,
env=_merge_env(env),
)
if isinstance(result, str):
return result
returncode, stdout, stderr, timed_out = result
return _format_result(
returncode=returncode,
workdir_display=str(workdir.relative_to(get_workspace_root())),
command=command,
stdout=stdout,
stderr=stderr,
timed_out=timed_out,
return format_command_result(
returncode=result.exit_code,
workdir_display=result.cwd_display,
command=result.command,
stdout=result.stdout,
stderr=result.stderr,
timed_out=result.timed_out,
)
@@ -0,0 +1,214 @@
from __future__ import annotations
import json
import shlex
from typing import Any, cast
from plyngent.agent import tool
from plyngent.tools.workspace import WorkspaceError
from .command_exec import (
DEFAULT_MAX_BATCH_STEPS,
DEFAULT_MAX_OUTPUT_CHARS,
DEFAULT_TIMEOUT_SECONDS,
CommandStepResult,
execute_argv,
truncate_output,
)
BATCH_OUTPUT_SOFT_CAP_FACTOR = 4
def _as_bool(value: object, *, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def _as_float(value: object, *, default: float) -> float:
if value is None:
return default
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str) and value.strip():
return float(value)
return default
def _as_str_dict(value: object) -> dict[str, str] | None:
if value is None:
return None
if not isinstance(value, dict):
msg = "env must be an object of string keys/values"
raise WorkspaceError(msg)
return {str(key): str(val) for key, val in cast("dict[object, object]", value).items()}
def _parse_step(raw: object, index: int) -> dict[str, Any]:
if not isinstance(raw, dict):
msg = f"commands[{index}] must be an object"
raise WorkspaceError(msg)
data = cast("dict[str, object]", raw)
command = data.get("command")
if not isinstance(command, list) or not command:
msg = f"commands[{index}].command must be a non-empty argv list"
raise WorkspaceError(msg)
command_parts = cast("list[object]", command)
argv = [part for part in command_parts if isinstance(part, str)]
if len(argv) != len(command_parts):
msg = f"commands[{index}].command must be a list of strings"
raise WorkspaceError(msg)
cwd = data.get("cwd")
stdin = data.get("stdin")
stop = data.get("stop_on_error", None)
return {
"command": argv,
"cwd": str(cwd) if isinstance(cwd, str) and cwd else None,
"env": _as_str_dict(data.get("env")),
"stdin": None if stdin is None else str(stdin),
"pipe_out": _as_bool(data.get("pipe_out"), default=False),
"mix_stderr": _as_bool(data.get("mix_stderr"), default=False),
"stop_on_error": None if stop is None else _as_bool(stop, default=True),
"timeout_seconds": _as_float(data.get("timeout_seconds"), default=DEFAULT_TIMEOUT_SECONDS),
}
def _normalize_commands(commands: list[dict[str, object]] | str) -> list[object]:
if isinstance(commands, str):
try:
loaded: object = json.loads(commands)
except json.JSONDecodeError as exc:
msg = f"commands must be a JSON array: {exc}"
raise WorkspaceError(msg) from exc
if not isinstance(loaded, list):
msg = "commands must be a JSON array of step objects"
raise WorkspaceError(msg)
return cast("list[object]", loaded)
return cast("list[object]", commands)
def _format_step(index: int, result: CommandStepResult, *, pipe_out: bool) -> str:
return "\n".join(
[
f"--- step {index} ---",
f"exit_code={result.exit_code if result.exit_code is not None else ''}",
f"timed_out={'true' if result.timed_out else 'false'}",
f"cwd={result.cwd_display}",
f"cmd={shlex.join(result.command)}",
f"pipe_out={'true' if pipe_out else 'false'}",
f"mix_stderr={'true' if result.mix_stderr else 'false'}",
"--- stdout ---",
truncate_output(result.stdout, "stdout"),
"--- stderr ---",
truncate_output(result.stderr, "stderr"),
]
)
def _merge_env(base_env: dict[str, str] | None, step_env: dict[str, str] | None) -> dict[str, str] | None:
if base_env is None and step_env is None:
return None
return {**(base_env or {}), **(step_env or {})}
async def _run_batch_steps(
steps: list[dict[str, Any]],
*,
cwd: str,
env: dict[str, str] | None,
stop_on_error: bool,
) -> tuple[list[tuple[dict[str, Any], CommandStepResult]], bool]:
results: list[tuple[dict[str, Any], CommandStepResult]] = []
prev_capture: str | None = None
prev_piped = False
stopped_early = False
total_chars = 0
for index, step in enumerate(steps):
stdin_text = prev_capture if prev_piped else step["stdin"]
result = await execute_argv(
step["command"],
cwd=step["cwd"] if step["cwd"] is not None else cwd,
env=_merge_env(env, step["env"]),
stdin=stdin_text,
timeout_seconds=step["timeout_seconds"],
mix_stderr=step["mix_stderr"],
)
results.append((step, result))
prev_capture = result.captured
prev_piped = bool(step["pipe_out"])
total_chars += len(result.stdout) + len(result.stderr)
failed = bool(result.timed_out) or result.exit_code != 0
effective_stop = stop_on_error if step["stop_on_error"] is None else bool(step["stop_on_error"])
if failed and effective_stop:
return results, True
if total_chars > DEFAULT_MAX_OUTPUT_CHARS * BATCH_OUTPUT_SOFT_CAP_FACTOR:
return results, True
del index # used for clarity in loop only
return results, stopped_early
@tool
async def run_command_batch(
commands: list[dict[str, object]] | str,
*,
cwd: str = ".",
env: dict[str, str] | None = None,
stop_on_error: bool = True,
) -> str:
"""Run a serial batch of argv commands (no shell).
Each element of ``commands`` is an object::
{
"command": ["git", "status"], # required argv
"cwd": ".", # optional, else batch cwd
"env": {"FOO": "1"}, # optional overlay
"stdin": "...", # optional; unused if previous pipe_out
"pipe_out": false, # if true, feed capture into *next* stdin
"mix_stderr": false, # OS-level merge stderr into capture
"stop_on_error": null, # override batch stop_on_error
"timeout_seconds": 30
}
``pipe_out`` is set on the **provider** step. Last-step ``pipe_out`` is an error.
Default ``stop_on_error`` is true. Max 20 steps; total output budget 32k chars.
"""
try:
raw_steps = _normalize_commands(commands)
if not raw_steps:
return "error: commands must not be empty"
if len(raw_steps) > DEFAULT_MAX_BATCH_STEPS:
return f"error: at most {DEFAULT_MAX_BATCH_STEPS} commands per batch"
steps = [_parse_step(item, i) for i, item in enumerate(raw_steps)]
if steps[-1]["pipe_out"]:
return "error: last command has pipe_out=true but there is no next step"
results, stopped_early = await _run_batch_steps(steps, cwd=cwd, env=env, stop_on_error=stop_on_error)
body = "\n".join(
_format_step(i, result, pipe_out=bool(step["pipe_out"])) for i, (step, result) in enumerate(results)
)
if len(body) > DEFAULT_MAX_OUTPUT_CHARS:
body = truncate_output(body, "batch")
last_exit = results[-1][1].exit_code if results else ""
return "\n".join(
[
f"steps={len(steps)} ran={len(results)} "
f"stop_on_error={'true' if stop_on_error else 'false'} "
f"stopped_early={'true' if stopped_early else 'false'}",
body,
"--- summary ---",
f"last_exit={last_exit if last_exit is not None else ''}",
]
)
except WorkspaceError as exc:
return f"error: {exc}"
except (TypeError, ValueError) as exc:
return f"error: invalid batch arguments: {exc}"
+20 -11
View File
@@ -6,22 +6,31 @@ from plyngent.tools.workspace import WorkspaceError
from .pty_session import PtyManager
@tool
def write_pty(session_id: int, data: str) -> str:
"""Write text to a PTY session (interactive input). Does not append a newline."""
try:
PtyManager.write(session_id, data)
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}"
def write_pty_payload(session_id: int, raw: str) -> str:
"""Write raw bytes (as str) to the PTY and format the tool status string."""
PtyManager.write(session_id, raw)
session = PtyManager.refresh(session_id)
exit_disp = "" if session.exit_code is None else str(session.exit_code)
return "\n".join(
[
f"session_id={session_id}",
f"alive={'true' if session.alive else 'false'}",
f"exit_code={exit_disp}",
f"wrote={len(data)}",
f"wrote={len(raw.encode())}",
]
)
@tool
def write_pty(session_id: int, data: str) -> str:
"""Write **literal** text to a PTY session. Does not append a newline.
``data`` is sent unchanged (no ``ctrl+x`` / ``\\\\xHH`` expansion). For
control sequences use :func:`write_pty_keys`.
"""
try:
return write_pty_payload(session_id, data)
except WorkspaceError as exc:
return f"error: {exc}"
except OSError as exc:
return f"error: failed to write PTY: {exc}"
@@ -0,0 +1,30 @@
from __future__ import annotations
from plyngent.agent import tool
from plyngent.tools.workspace import WorkspaceError
from .pty_terminal import decode_write_data
from .write_pty import write_pty_payload
@tool
def write_pty_keys(session_id: int, data: str) -> str:
"""Write to a PTY after expanding key escapes (never for normal typing).
Use this only for control sequences. Prefer :func:`write_pty` for plain text
so strings like ``press ctrl+c`` are not rewritten.
Escapes (literal characters in ``data``):
- ``\\\\n`` ``\\\\r`` ``\\\\t`` ``\\\\e``/``\\\\E`` (ESC) ``\\\\0``
- ``\\\\xHH`` byte, ``\\\\uHHHH`` Unicode code point
- ``ctrl+c`` / ``ctrl+x`` ... (case-insensitive)
- ``key=esc|enter|tab|up|down|left|right``
"""
try:
raw = decode_write_data(data)
return write_pty_payload(session_id, raw)
except WorkspaceError as exc:
return f"error: {exc}"
except OSError as exc:
return f"error: failed to write PTY: {exc}"
+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"
)
+137
View File
@@ -0,0 +1,137 @@
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).
Non-empty stack with open (pending/in_progress) items = unfinished work.
All-terminal but non-empty = hygiene only (pop/clear). 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).
Prefer after TOP items are done/cancelled so the stack does not stay
non-empty with only finished work.
"""
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.
Open items are unfinished work: mark done/cancelled when the work is truly
finished (not just deferred). Pop the TOP group when that breakdown level
is complete so the stack does not linger as false open work.
"""
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]
+160 -9
View File
@@ -1,6 +1,10 @@
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
DEFAULT_COMMAND_DENYLIST: frozenset[str] = frozenset(
{
@@ -26,6 +30,15 @@ DEFAULT_COMMAND_DENYLIST: frozenset[str] = frozenset(
}
)
# Max concurrent temporary workspaces registered in one process.
MAX_TEMPORARY_WORKSPACES = 16
# Timed human override for command denylist (independent of YOLO soft-confirm).
DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS = 30.0
# Hook: (basename, argv, timeout_seconds) -> True allow for this session basename.
type PolicyConfirmHook = Callable[[str, Sequence[str], float], bool]
class WorkspaceError(ValueError):
"""Raised when a path or command violates workspace policy."""
@@ -35,6 +48,21 @@ class _WorkspaceState:
root: Path | None = None
path_denylist: tuple[str, ...] = ()
command_denylist: frozenset[str] = DEFAULT_COMMAND_DENYLIST
# Extra roots allowed for resolve_path (e.g. temporary workspaces under system temp).
allowlist: list[Path]
# Paths created by new_temporary_workspace (subset of allowlist); cleaned on chat exit.
temporary_owned: list[Path]
# Basenames the human allowed this process (denylist override, not permanent).
policy_allowed_commands: set[str]
policy_confirm_hook: PolicyConfirmHook | None
policy_confirm_timeout_seconds: float
def __init__(self) -> None:
self.allowlist = []
self.temporary_owned = []
self.policy_allowed_commands = set()
self.policy_confirm_hook = None
self.policy_confirm_timeout_seconds = DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS
_state = _WorkspaceState()
@@ -59,7 +87,7 @@ def get_workspace_root() -> Path:
def clear_workspace_root() -> None:
"""Clear workspace root (mainly for tests)."""
"""Clear workspace root (mainly for tests). Does not clear allowlist."""
_state.root = None
@@ -76,24 +104,124 @@ def get_path_denylist() -> tuple[str, ...]:
def set_command_denylist(names: list[str] | tuple[str, ...] | frozenset[str] | None) -> None:
"""Set denied command basenames (None restores defaults)."""
_state.command_denylist = DEFAULT_COMMAND_DENYLIST if names is None else frozenset(names)
# Session overrides only make sense against the active denylist.
_state.policy_allowed_commands &= _state.command_denylist
def get_command_denylist() -> frozenset[str]:
return _state.command_denylist
def set_policy_confirm_hook(hook: PolicyConfirmHook | None) -> None:
"""Register a timed human confirm for denylisted commands (CLI installs this)."""
_state.policy_confirm_hook = hook
def get_policy_confirm_hook() -> PolicyConfirmHook | None:
return _state.policy_confirm_hook
def set_policy_confirm_timeout(seconds: float) -> None:
"""Timeout for policy confirm prompts (must be > 0)."""
if seconds <= 0:
msg = "policy confirm timeout must be > 0"
raise WorkspaceError(msg)
_state.policy_confirm_timeout_seconds = float(seconds)
def get_policy_confirm_timeout() -> float:
return _state.policy_confirm_timeout_seconds
def clear_policy_allowed_commands() -> None:
"""Drop session-scoped denylist overrides (tests / chat exit)."""
_state.policy_allowed_commands.clear()
def grant_policy_command(basename: str) -> None:
"""Allow *basename* for the rest of this process despite the denylist."""
name = basename.strip()
if name:
_state.policy_allowed_commands.add(name)
def add_workspace_allowlist(root: Path | str, *, owned: bool = False) -> Path:
"""Allow tool paths under *root* in addition to the primary workspace.
When *owned* is true, the path is also registered for chat-exit cleanup
(only paths created by :func:`new_temporary_workspace`).
"""
path = Path(root).expanduser().resolve()
if not path.is_dir():
msg = f"allowlist root is not a directory: {path}"
raise WorkspaceError(msg)
if path not in _state.allowlist:
if len(_state.allowlist) >= MAX_TEMPORARY_WORKSPACES and owned:
msg = f"too many temporary workspaces (max {MAX_TEMPORARY_WORKSPACES})"
raise WorkspaceError(msg)
_state.allowlist.append(path)
if owned and path not in _state.temporary_owned:
_state.temporary_owned.append(path)
return path
def list_workspace_allowlist() -> list[Path]:
"""Return a copy of extra allowed roots (not including the primary workspace)."""
return list(_state.allowlist)
def clear_workspace_allowlist() -> None:
"""Clear allowlist and owned-temp registry (tests). Does not delete directories."""
_state.allowlist.clear()
_state.temporary_owned.clear()
def pop_owned_temporary_workspaces() -> list[Path]:
"""Return and clear the owned temporary workspace list (for chat-exit cleanup).
Paths remain on the allowlist until the caller removes them via
:func:`remove_workspace_allowlist`.
"""
owned = list(_state.temporary_owned)
_state.temporary_owned.clear()
return owned
def remove_workspace_allowlist(root: Path | str) -> None:
"""Drop *root* from the allowlist if present."""
path = Path(root).expanduser().resolve()
while path in _state.allowlist:
_state.allowlist.remove(path)
def _under_any_root(resolved: Path) -> bool:
roots: list[Path] = []
if _state.root is not None:
roots.append(_state.root)
roots.extend(_state.allowlist)
for root in roots:
try:
_ = resolved.relative_to(root)
except ValueError:
continue
return True
return False
def resolve_path(path: str | Path) -> Path:
"""Resolve ``path`` under the workspace root; reject escapes and denylist hits."""
"""Resolve ``path`` under the workspace root or an allowlisted temp root.
Relative paths resolve against the **primary** workspace root. Absolute
paths may also land under a temporary workspace allowlist entry.
"""
root = get_workspace_root()
candidate = Path(path)
if not candidate.is_absolute():
candidate = root / candidate
resolved = candidate.expanduser().resolve()
try:
_ = resolved.relative_to(root)
except ValueError as exc:
if not _under_any_root(resolved):
msg = f"path escapes workspace root ({root}): {path}"
raise WorkspaceError(msg) from exc
raise WorkspaceError(msg)
# Normalize separators so denylist entries like ``/secrets/`` match on Windows.
resolved_str = str(resolved).replace("\\", "/")
for pattern in _state.path_denylist:
@@ -104,11 +232,34 @@ def resolve_path(path: str | Path) -> Path:
def check_command_allowed(argv: list[str]) -> None:
"""Raise if argv is empty or the executable basename is denylisted."""
"""Raise if argv is empty or the executable basename is denylisted.
Denylisted basenames are not hard-rejected when a policy confirm hook is
installed: the human is asked (with a timeout; default deny). Session grants
skip re-prompting for the same basename. Independent of YOLO soft-confirm.
"""
if not argv:
msg = "command argv must not be empty"
raise WorkspaceError(msg)
binary = Path(argv[0]).name
if binary in _state.command_denylist:
msg = f"command denied by policy (basename {binary!r} is blocked)"
if binary not in _state.command_denylist:
return
if binary in _state.policy_allowed_commands:
return
hook = _state.policy_confirm_hook
if hook is not None:
timeout = _state.policy_confirm_timeout_seconds
try:
allowed = bool(hook(binary, list(argv), timeout))
except Exception as exc:
msg = f"command denied by policy (basename {binary!r}; confirm failed: {exc})"
raise WorkspaceError(msg) from exc
if allowed:
_state.policy_allowed_commands.add(binary)
return
msg = (
f"command denied by policy (basename {binary!r} is blocked; user declined or timed out after {timeout:g}s)"
)
raise WorkspaceError(msg)
msg = f"command denied by policy (basename {binary!r} is blocked)"
raise WorkspaceError(msg)
+141 -2
View File
@@ -30,6 +30,7 @@ from plyngent.lmproto.openai_compatible.model import (
ChatCompletionsParam,
ChunkChoice,
DeltaMessage,
FinishReason,
StreamFunctionDelta,
StreamToolCallDelta,
ToolChatMessage,
@@ -61,6 +62,28 @@ def _chunks_from_response(response: ChatCompletionResponse) -> list[ChatCompleti
],
)
)
# Emit terminal finish_reason when content was streamed without one.
fr = response.choices[0].finish_reason
if (
fr
and (isinstance(message.content, str) and message.content)
and (message.tool_calls is UNSET or not message.tool_calls)
):
chunks.append(
ChatCompletionChunk(
id=response.id,
object="chat.completion.chunk",
created=response.created,
model=response.model,
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(),
finish_reason=fr,
)
],
)
)
tool_calls = message.tool_calls
if tool_calls is not UNSET and tool_calls:
deltas: list[StreamToolCallDelta] = []
@@ -152,7 +175,8 @@ class ScriptedClient:
def _response(
message: AssistantChatMessage,
*,
usage: dict[str, int] | None = None,
usage: dict[str, object] | None = None,
finish_reason: FinishReason | None = "stop",
) -> ChatCompletionResponse:
return ChatCompletionResponse(
id="1",
@@ -164,7 +188,7 @@ def _response(
index=0,
message=message,
logprobs={},
finish_reason="stop",
finish_reason=finish_reason,
)
],
system_fingerprint="",
@@ -825,3 +849,118 @@ async def test_tool_result_char_budget() -> None:
assert len(tool_msgs) == 1
assert tool_msgs[0].content.startswith("x" * 20)
assert "truncated" in tool_msgs[0].content
async def test_empty_completion_raises() -> None:
client = ScriptedClient([_response(AssistantChatMessage(content=None))])
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
with pytest.raises(RuntimeError, match="empty model completion"):
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
async def test_length_finish_raises() -> None:
client = ScriptedClient(
[
_response(
AssistantChatMessage(content="partial"),
finish_reason="length",
)
]
)
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
with pytest.raises(RuntimeError, match="truncated"):
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
async def test_content_filter_finish_raises() -> None:
client = ScriptedClient(
[
_response(
AssistantChatMessage(content=""),
finish_reason="content_filter",
)
]
)
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
with pytest.raises(RuntimeError, match="content filter"):
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
async def test_stream_missing_terminal_empty_raises() -> None:
class EmptyStreamClient:
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
) -> ChatCompletionResponse: ...
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[True]
) -> AsyncIterator[ChatCompletionChunk]: ...
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
del param
if not stream:
return _response(AssistantChatMessage(content="x"))
async def chunks() -> AsyncIterator[ChatCompletionChunk]:
# Usage-only style chunk with no finish_reason and no content.
yield ChatCompletionChunk(
id="1",
object="chat.completion.chunk",
created=0,
model="t",
choices=[],
usage={"prompt_tokens": 1, "completion_tokens": 0, "total_tokens": 1},
)
return chunks()
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
with pytest.raises(RuntimeError, match="stream ended without a terminal"):
_ = [e async for e in run_chat_loop(EmptyStreamClient(), messages, model="m", stream=True)]
async def test_stream_payload_without_finish_reason_ok() -> None:
"""Some providers omit finish_reason; non-empty text still counts as terminal."""
class PayloadNoFinishClient:
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
) -> ChatCompletionResponse: ...
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[True]
) -> AsyncIterator[ChatCompletionChunk]: ...
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
del param
if not stream:
return _response(AssistantChatMessage(content="ok"))
async def chunks() -> AsyncIterator[ChatCompletionChunk]:
yield ChatCompletionChunk(
id="1",
object="chat.completion.chunk",
created=0,
model="t",
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(content="ok"),
finish_reason=None,
)
],
)
return chunks()
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
events = [e async for e in run_chat_loop(PayloadNoFinishClient(), messages, model="m", stream=True)]
assert any(isinstance(e, TextDeltaEvent) and e.content == "ok" for e in events)
+26
View File
@@ -8,6 +8,7 @@ from plyngent.agent.responses_bridge import (
chat_param_to_responses_kwargs,
response_to_assistant_message,
response_to_chat_completion,
responses_status_to_finish_reason,
tool_items_to_response_tools,
)
from plyngent.agent.responses_client import ResponsesChatClient
@@ -100,6 +101,31 @@ def test_response_to_assistant_with_tools() -> None:
assert completion.choices[0].message.content == "done"
assert isinstance(completion.usage, dict)
assert completion.usage["input_tokens"] == 10
assert completion.choices[0].finish_reason == "tool_calls"
def test_responses_status_to_finish_reason_incomplete() -> None:
body = {
"id": "resp_i",
"object": "response",
"created_at": 1,
"model": "gpt-test",
"status": "incomplete",
"incomplete_details": {"reason": "max_output_tokens"},
"output": [
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"status": "incomplete",
"content": [{"type": "output_text", "text": "partial", "annotations": []}],
}
],
}
resp = msgspec.convert(body, Response)
assert responses_status_to_finish_reason(resp, has_tool_calls=False) == "length"
completion = response_to_chat_completion(resp)
assert completion.choices[0].finish_reason == "length"
def test_chat_param_to_responses_kwargs() -> None:
+431
View File
@@ -0,0 +1,431 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, overload
import pytest
from msgspec import UNSET
from plyngent.agent import ChatAgent, ToolRegistry
from plyngent.agent.todo_nag import inject_todo_nag, parse_todo_nag_strategy
from plyngent.agent.todo_stack import TodoStack, parse_push_titles
from plyngent.config.models import DatabaseConfig
from plyngent.lmproto.openai_compatible.model import (
AnyChatMessage,
AssistantChatMessage,
ChatCompletionChoice,
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
DeveloperChatMessage,
ToolChatMessage,
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()
item = stack.push("work")
stack.begin_turn()
# Open items always need review, even if todo_* was used this turn.
assert stack.needs_review()
stack.mark_touched()
assert stack.needs_review()
stack.update(item.id, status="done")
stack.begin_turn()
# Terminal-only stack: review only when untouched this turn.
assert stack.needs_review()
stack.mark_touched()
assert not stack.needs_review()
def test_parse_todo_nag_strategy() -> None:
assert parse_todo_nag_strategy("developer") == "developer"
assert parse_todo_nag_strategy("SYNTHETIC-TOOL") == "synthetic_tool"
assert parse_todo_nag_strategy("nope") == "developer"
assert parse_todo_nag_strategy(None) == "developer"
# Legacy alias — mid-turn system was not a useful Responses channel.
assert parse_todo_nag_strategy("system") == "developer"
def test_inject_todo_nag_strategies() -> None:
from plyngent.agent.events import ToolCallEvent, ToolResultEvent
from plyngent.agent.todo_nag import (
inject_todo_nag_for_stack_with_events,
inject_todo_nag_with_events,
synthetic_todo_list_result,
)
body = "[TODO OPEN WORK] test"
messages: list[AnyChatMessage] = []
assert inject_todo_nag(messages, body, strategy="none") is False
assert messages == []
messages = []
assert inject_todo_nag(messages, body, strategy="developer") is True
assert isinstance(messages[0], DeveloperChatMessage)
assert body in messages[0].content
messages = []
assert inject_todo_nag(messages, body, strategy="user") is True
assert isinstance(messages[0], UserChatMessage)
# synthetic_tool: real todo_list-shaped body (stack.render), not OPEN WORK prose
stack = TodoStack()
_ = stack.push("synthetic body item")
real = synthetic_todo_list_result(stack)
assert real == stack.render()
assert "[TODO OPEN WORK]" not in real
assert "synthetic body item" in real
messages = []
ok, events = inject_todo_nag_for_stack_with_events(
messages,
stack,
kind="end_of_turn",
strategy="synthetic_tool",
)
assert ok is True
assert len(messages) == 2
assert isinstance(messages[0], AssistantChatMessage)
tool_calls = messages[0].tool_calls
assert tool_calls is not UNSET and tool_calls
assert isinstance(messages[1], ToolChatMessage)
tool_content = messages[1].content
assert isinstance(tool_content, str)
assert tool_content == real
assert messages[1].tool_call_id.startswith("todo-nag-")
assert len(events) == 2
assert isinstance(events[0], ToolCallEvent)
assert isinstance(events[1], ToolResultEvent)
result_event = events[1]
assert isinstance(result_event, ToolResultEvent)
result_content = result_event.message.content
assert isinstance(result_content, str)
assert result_content == real
# Raw inject still accepts an explicit body (e.g. tests / custom)
messages = []
ok2, events2 = inject_todo_nag_with_events(messages, body, strategy="synthetic_tool")
assert ok2
assert len(events2) == 2
assert isinstance(events2[1], ToolResultEvent)
raw_content = events2[1].message.content
assert isinstance(raw_content, str)
assert body in raw_content
def test_todo_prompts_signal_undone_work() -> None:
stack = TodoStack()
item = stack.push("open work")
reminder = stack.turn_reminder_prompt()
assert "[TODO OPEN WORK]" in reminder
assert "Stack not empty" in reminder
assert "unfinished work" in reminder.lower()
assert "open work" in reminder
review = stack.review_prompt()
assert "[TODO OPEN WORK]" in review
assert "undone work" in review.lower() or "open item" in review.lower()
assert "open work" in review
assert "t1:open work" in review or "open work" in review
stack.update(item.id, status="done")
terminal_review = stack.review_prompt()
assert "[TODO HYGIENE]" in terminal_review
assert "done/cancelled" in terminal_review
terminal_reminder = stack.turn_reminder_prompt()
assert "[TODO HYGIENE]" in terminal_reminder
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 WORK]" in m.content for m in agent.messages)
assert not any(isinstance(m, UserChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages)
finally:
set_todo_stack(None)
@pytest.mark.asyncio
async def test_loop_injects_todo_review_when_open_after_touch() -> None:
"""Open items still trigger end-of-turn review even if todo_* ran this turn."""
stack = TodoStack()
_ = stack.push("still open")
stack.begin_turn()
stack.mark_touched() # simulates todo_list earlier in the turn
assert stack.needs_review()
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 WORK]" in m.content for m in agent.messages)
finally:
set_todo_stack(None)
@pytest.mark.asyncio
async def test_loop_synthetic_tool_nag_strategy() -> 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,
todo_nag_strategy="synthetic_tool",
)
set_todo_stack(stack)
try:
async for _event in agent.run("do stuff"):
pass
# Result body is real todo_list shape (render), not OPEN WORK prose.
assert any(
isinstance(m, ToolChatMessage) and "open work" in m.content and "[TODO OPEN WORK]" not in m.content
for m in agent.messages
)
assert not any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages)
finally:
set_todo_stack(None)
@pytest.mark.asyncio
async def test_loop_none_nag_strategy_skips_inject() -> 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,
todo_nag_strategy="none",
)
set_todo_stack(stack)
try:
async for _event in agent.run("do stuff"):
pass
# One completion only — no end-of-turn continue from nag.
assert client.calls == 1
assert not any(
isinstance(m, (DeveloperChatMessage, ToolChatMessage)) and "[TODO OPEN WORK]" in getattr(m, "content", "")
for m in agent.messages
)
finally:
set_todo_stack(None)
+95 -3
View File
@@ -1,10 +1,102 @@
from __future__ import annotations
from pathlib import Path
import pytest
from click.testing import CliRunner
from plyngent.cli.app import main
from plyngent.cli.app import _database_config, main
from plyngent.config import load as load_config
def test_database_config_memory_url_kept_with_warning(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Explicit ``url = ":memory:"`` is not rewritten; CLI warns (when not quiet)."""
path = tmp_path / "mem.toml"
_ = path.write_text(
"""
[database]
implementation = "sqlite"
url = ":memory:"
""",
encoding="utf-8",
)
store = load_config(path)
cfg = _database_config(store, quiet=False)
assert cfg.url == ":memory:"
assert cfg.implementation == "sqlite"
err = capsys.readouterr().err
assert ":memory:" in err
assert "not persisted" in err.lower() or "warning" in err.lower()
def test_database_config_memory_url_quiet_no_warn(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
path = tmp_path / "mem.toml"
_ = path.write_text(
"""
[database]
implementation = "sqlite"
url = ":memory:"
""",
encoding="utf-8",
)
store = load_config(path)
cfg = _database_config(store, quiet=True)
assert cfg.url == ":memory:"
assert capsys.readouterr().err == ""
def test_database_config_unset_url_uses_user_data(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Unset/empty url → durable user-data chat.db."""
data_dir = tmp_path / "user-data"
_ = data_dir.mkdir()
def fake_user_data_path(*_args: object, **_kwargs: object) -> Path:
return data_dir
monkeypatch.setattr("plyngent.cli.app.user_data_path", fake_user_data_path)
path = tmp_path / "empty-url.toml"
_ = path.write_text(
"""
[database]
implementation = "sqlite"
""",
encoding="utf-8",
)
store = load_config(path)
cfg = _database_config(store, quiet=True)
assert cfg.url == str(data_dir / "chat.db")
def test_database_config_omitted_section_uses_user_data(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Omitted [database] → durable user-data chat.db (url default None)."""
data_dir = tmp_path / "user-data2"
_ = data_dir.mkdir()
def fake_user_data_path(*_args: object, **_kwargs: object) -> Path:
return data_dir
monkeypatch.setattr("plyngent.cli.app.user_data_path", fake_user_data_path)
path = tmp_path / "no-db.toml"
_ = path.write_text(
"""
[providers.local]
preset = "openai-compatible"
url = "http://127.0.0.1:1/v1"
access_key_or_token = "x"
""",
encoding="utf-8",
)
store = load_config(path)
assert store.database.get("url") is None
cfg = _database_config(store, quiet=True)
assert cfg.url == str(data_dir / "chat.db")
def test_providers_list() -> None:
+52
View File
@@ -99,11 +99,63 @@ async def test_compact_to_new_session(tmp_path: Path) -> None:
assert "compacted goals" in summary
loaded = await memory.list_messages(new_id)
assert any("compacted goals" in getattr(m, "content", "") for m in loaded)
# After compact, RAM matches DB and the persist cursor treats seed as stored.
assert len(state.agent.messages) == len(loaded)
assert state.agent.persist_from == len(state.agent.messages)
# Old session still exists and is listable
sessions = await memory.list_sessions(workspace=tmp_path)
ids = {s.sid for s in sessions}
assert old_id in ids
assert new_id in ids
# Simulate restart: resume compact session, continue without duplicating seed.
state2 = ReplState(
config=config,
memory=memory,
workspace=tmp_path,
provider_name="local",
provider=provider,
model="gpt-test",
tools_enabled=False,
)
state2.client = SummaryClient()
await state2.resume_session(new_id)
assert len(state2.agent.messages) == len(loaded)
assert state2.agent.persist_from == len(state2.agent.messages)
assert any("compacted goals" in getattr(m, "content", "") for m in state2.agent.messages)
# Full original conversation still only on the old session.
old_loaded = await memory.list_messages(old_id)
assert any(isinstance(m, UserChatMessage) and m.content == "do work" for m in old_loaded)
finally:
await memory.close()
async def test_rebuild_client_preserves_persist_cursor(tmp_path: Path) -> None:
_ = set_workspace_root(tmp_path)
memory = await MemoryStore.open(DatabaseConfig())
try:
provider = OpenAIProvider(access_key_or_token="sk-test")
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
config.providers = {"local": provider}
state = ReplState(
config=config,
memory=memory,
workspace=tmp_path,
provider_name="local",
provider=provider,
model="gpt-test",
tools_enabled=False,
)
state.client = SummaryClient()
await state.new_session("s")
state.agent.replace_messages(
[UserChatMessage(content="hi"), AssistantChatMessage(content="yo")],
persisted=True,
)
assert state.agent.persist_from == 2
state.rebuild_client()
assert len(state.agent.messages) == 2
assert state.agent.persist_from == 2
finally:
await memory.close()
+86 -12
View File
@@ -15,10 +15,13 @@ from plyngent.cli.editor import (
)
def test_get_editor(monkeypatch: pytest.MonkeyPatch) -> None:
def test_get_editor_prefers_visual(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("EDITOR", raising=False)
monkeypatch.delenv("VISUAL", raising=False)
assert get_editor() is None
monkeypatch.setenv("EDITOR", " codium --wait ")
monkeypatch.setenv("EDITOR", " nano ")
assert get_editor() == "nano"
monkeypatch.setenv("VISUAL", " codium --wait ")
assert get_editor() == "codium --wait"
@@ -49,7 +52,8 @@ def test_open_in_editor_splits_args(tmp_path: Path, monkeypatch: pytest.MonkeyPa
return Result()
monkeypatch.setattr("plyngent.cli.editor.subprocess.run", fake_run)
open_in_editor(path, editor="codium --wait")
outcome = open_in_editor(path, editor="codium --wait")
assert outcome == "waited"
assert len(calls) == 1
assert calls[0][0] == "codium"
assert calls[0][1] == "--wait"
@@ -57,10 +61,33 @@ def test_open_in_editor_splits_args(tmp_path: Path, monkeypatch: pytest.MonkeyPa
assert path.is_file()
def test_open_in_editor_missing_editor(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def test_open_in_editor_system_fallback(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("EDITOR", raising=False)
with pytest.raises(Exception, match="EDITOR"):
open_in_editor(tmp_path / "x.toml", editor=None)
monkeypatch.delenv("VISUAL", raising=False)
path = tmp_path / "x.toml"
opened: list[str] = []
def fake_system(p: Path) -> None:
opened.append(str(p))
monkeypatch.setattr("plyngent.cli.editor._open_with_system_default", fake_system)
outcome = open_in_editor(path, editor=None, allow_system_open=True)
assert outcome == "system"
assert opened == [str(path)]
assert path.is_file()
def test_open_in_editor_no_fallback_requires_editor(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("EDITOR", raising=False)
monkeypatch.delenv("VISUAL", raising=False)
with pytest.raises(Exception, match=r"VISUAL|EDITOR"):
open_in_editor(tmp_path / "x.toml", editor=None, allow_system_open=False)
def test_edit_text_in_editor(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -99,9 +126,13 @@ def test_edit_text_empty_cancels(monkeypatch: pytest.MonkeyPatch) -> None:
assert edit_text_in_editor("") is None
def test_prompt_edit_no_editor(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def test_edit_text_no_system_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
from plyngent.cli.editor import edit_text_in_editor
monkeypatch.delenv("EDITOR", raising=False)
assert prompt_edit_config(tmp_path / "p.toml") is False
monkeypatch.delenv("VISUAL", raising=False)
with pytest.raises(Exception, match=r"VISUAL|EDITOR"):
edit_text_in_editor("x")
def test_prompt_edit_declined(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -111,7 +142,7 @@ def test_prompt_edit_declined(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -
return False
monkeypatch.setattr("click.confirm", _confirm)
assert prompt_edit_config(tmp_path / "p.toml", reason="empty") is False
assert prompt_edit_config(tmp_path / "p.toml", reason="empty") is None
def test_prompt_edit_accepted(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -124,15 +155,40 @@ def test_prompt_edit_accepted(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -
monkeypatch.setattr("click.confirm", _confirm)
opened: list[Path] = []
def fake_open(p: Path, *, editor: str | None = None) -> None:
del editor
def fake_open(
p: Path,
*,
editor: str | None = None,
ensure_exists: bool = True,
allow_system_open: bool = True,
) -> str:
del editor, ensure_exists, allow_system_open
opened.append(p)
return "waited"
monkeypatch.setattr("plyngent.cli.editor.open_in_editor", fake_open)
assert prompt_edit_config(path, reason="No providers.") is True
assert prompt_edit_config(path, reason="No providers.") == "waited"
assert opened == [path]
def test_prompt_edit_without_editor_can_system_open(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("EDITOR", raising=False)
monkeypatch.delenv("VISUAL", raising=False)
def _confirm(*_a: object, **_k: object) -> bool:
return True
monkeypatch.setattr("click.confirm", _confirm)
monkeypatch.setattr(
"plyngent.cli.editor.open_in_editor",
lambda *a, **k: "system",
)
assert prompt_edit_config(tmp_path / "p.toml") == "system"
def test_config_path_command(tmp_path: Path) -> None:
runner = CliRunner()
result = runner.invoke(main, ["config", "path", "--config", str(tmp_path / "a.toml")])
@@ -159,3 +215,21 @@ def test_config_edit_command(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
result = runner.invoke(main, ["config", "edit", "--config", str(path)])
assert result.exit_code == 0
assert calls and calls[0][-1] == str(path)
assert "edited" in result.output
def test_config_edit_system_fallback_message(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
path = tmp_path / "edit.toml"
monkeypatch.delenv("EDITOR", raising=False)
monkeypatch.delenv("VISUAL", raising=False)
monkeypatch.setattr(
"plyngent.cli.editor._open_with_system_default",
lambda p: None,
)
runner = CliRunner()
result = runner.invoke(main, ["config", "edit", "--config", str(path)])
assert result.exit_code == 0
assert "system default" in result.output.lower() or "system default" in (result.stderr or "").lower()
+67 -2
View File
@@ -1,16 +1,22 @@
from __future__ import annotations
import asyncio
import signal
from typing import TYPE_CHECKING
import pytest
from plyngent.cli.interrupt import (
allow_task_cancel,
pause_task_cancel_for_prompt,
run_in_prompt_thread,
set_sigint_reinstall,
)
from plyngent.cli.limits import prompt_continue_limit, prompt_continue_limit_async
from plyngent.cli.retry import run_cancellable
if TYPE_CHECKING:
import pytest
pass
def test_pause_task_cancel_for_prompt() -> None:
@@ -20,6 +26,16 @@ def test_pause_task_cancel_for_prompt() -> None:
assert allow_task_cancel() is True
def test_nested_pause_depth() -> None:
assert allow_task_cancel() is True
with pause_task_cancel_for_prompt():
assert allow_task_cancel() is False
with pause_task_cancel_for_prompt():
assert allow_task_cancel() is False
assert allow_task_cancel() is False
assert allow_task_cancel() is True
def test_prompt_continue_limit_under_pause(monkeypatch: pytest.MonkeyPatch) -> None:
def _confirm(*_a: object, **_k: object) -> bool:
assert allow_task_cancel() is False
@@ -36,7 +52,6 @@ async def test_run_in_prompt_thread_pauses_cancel() -> None:
def work() -> str:
return "ok"
# ContextVar may not propagate to worker threads; assert pause around the call.
result = await run_in_prompt_thread(work)
assert result == "ok"
assert allow_task_cancel() is True
@@ -48,3 +63,53 @@ async def test_prompt_continue_limit_async(monkeypatch: pytest.MonkeyPatch) -> N
monkeypatch.setattr("click.confirm", _confirm)
assert await prompt_continue_limit_async("too many rounds") is True
async def test_sigint_cancels_after_prompt_pause() -> None:
"""Regression: after a mid-turn prompt, SIGINT must still cancel the turn.
Previously, reinstalling the asyncio SIGINT handler while
allow_task_cancel was still False froze that value into the callback
forever (ContextVar snapshot at add_signal_handler).
"""
loop = asyncio.get_running_loop()
try:
loop.add_signal_handler(signal.SIGINT, lambda: None)
loop.remove_signal_handler(signal.SIGINT)
except NotImplementedError, RuntimeError, ValueError:
pytest.skip("asyncio signal handlers not available on this platform")
started = asyncio.Event()
cancelled = asyncio.Event()
async def hang() -> None:
started.set()
try:
await asyncio.sleep(3600)
except asyncio.CancelledError:
cancelled.set()
raise
async def turn() -> None:
# Simulate soft-confirm pause mid-turn, then keep streaming.
with pause_task_cancel_for_prompt():
assert allow_task_cancel() is False
assert allow_task_cancel() is True
await hang()
task = asyncio.create_task(run_cancellable(turn()))
await started.wait()
# Give run_cancellable a tick to install the handler after the pause reinstall.
await asyncio.sleep(0)
assert allow_task_cancel() is True
# Deliver SIGINT the same way Ctrl+C does under asyncio.
loop.call_soon(lambda: None) # ensure loop is processing
# Invoke the process signal: raise SIGINT to this process.
import os
os.kill(os.getpid(), signal.SIGINT)
with pytest.raises(asyncio.CancelledError):
await task
assert cancelled.is_set()
# Cleanup any leftover reinstall hook from run_cancellable
set_sigint_reinstall(None)
+29 -1
View File
@@ -1,6 +1,11 @@
from __future__ import annotations
from plyngent.cli.limits import install_cli_limit_hooks, prompt_confirm_tool, prompt_continue_limit
from plyngent.cli.limits import (
format_tool_confirm_box,
install_cli_limit_hooks,
prompt_confirm_tool,
prompt_continue_limit,
)
from plyngent.prompting import get_prompt_backend, temporary_backend
from plyngent.tools.process.pty_session import PtyManager
from tests.test_prompting import ScriptedBackend
@@ -24,9 +29,32 @@ def test_prompt_confirm_tool_default_deny() -> None:
assert prompt_confirm_tool("delete_path", {"path": "x"}, "delete path 'x'") is False
def test_format_tool_confirm_box_multiline() -> None:
reason = "run_command: python3 -c (review code before allow)\nargv:\n python3 -c print(1)\n-c code:\n print(1)"
box = format_tool_confirm_box("run_command", reason)
assert "" in box and "" in box
assert "confirm · tool 'run_command'" in box
assert "python3 -c" in box
assert "print(1)" in box
# Distinct lines, not one jammed row
assert box.count("\n") >= 4
def test_install_cli_limit_hooks() -> None:
from plyngent.tools.workspace import get_policy_confirm_hook, set_policy_confirm_hook
install_cli_limit_hooks()
assert callable(getattr(PtyManager, "_limit_continue", None))
assert get_policy_confirm_hook() is not None
PtyManager.set_limit_continue_hook(None)
set_policy_confirm_hook(None)
# Backend remains usable after install.
assert get_prompt_backend().is_interactive() or True
def test_policy_confirm_noninteractive_denies() -> None:
from plyngent.cli.limits import prompt_policy_command_confirm
from plyngent.prompting import NonInteractiveBackend
with temporary_backend(NonInteractiveBackend()):
assert prompt_policy_command_confirm("sudo", ["sudo", "id"], 1.0) is False
+37
View File
@@ -8,6 +8,7 @@ from plyngent.cli.models_source import (
fetch_remote_model_ids,
merge_model_choices,
model_choices_for_provider,
needs_remote_models_for_selection,
)
from plyngent.config.models import ModelConfig, OpenAICompatibleProvider
@@ -55,3 +56,39 @@ async def test_fetch_remote_model_ids() -> None:
with pytest.raises(TypeError, match="does not support"):
_ = await fetch_remote_model_ids(object())
@pytest.mark.asyncio
async def test_fetch_remote_model_ids_timeout() -> None:
import asyncio
class Slow:
async def models(self) -> list[str]:
await asyncio.sleep(10)
return ["late"]
with pytest.raises(RuntimeError, match="timed out"):
_ = await fetch_remote_model_ids(Slow(), timeout_seconds=0.05)
def test_needs_remote_models_for_selection() -> None:
multi = OpenAICompatibleProvider(
access_key_or_token="sk",
url="https://x/v1",
models={"a": ModelConfig(), "b": ModelConfig()},
)
single = OpenAICompatibleProvider(
access_key_or_token="sk",
url="https://x/v1",
models={"only": ModelConfig()},
)
empty = OpenAICompatibleProvider(
access_key_or_token="sk",
url="https://x/v1",
models={},
)
assert needs_remote_models_for_selection(multi, preferred_model="a", interactive=True) is False
assert needs_remote_models_for_selection(multi, preferred_model=None, interactive=False) is False
assert needs_remote_models_for_selection(single, preferred_model=None, interactive=True) is False
assert needs_remote_models_for_selection(multi, preferred_model=None, interactive=True) is True
assert needs_remote_models_for_selection(empty, preferred_model=None, interactive=True) is True
+17
View File
@@ -183,3 +183,20 @@ def test_complete_slash_args_from_registry(tmp_path: object) -> None:
assert complete_slash_args(state, "/help", "st") == ["status", "stream"]
assert complete_slash_args(state, "/yolo", "") == ["on", "off", "once"]
assert complete_slash_args(state, "/yolo", "o") == ["on", "off", "once"]
assert "push" in complete_slash_args(state, "/todos", "p")
assert "pop" in complete_slash_args(state, "/todos", "p")
assert "--persist" in complete_slash_args(state, "/model", "--p")
assert "--full" in complete_slash_args(state, "/history", "--f")
assert "32" in complete_slash_args(state, "/rounds", "3")
from plyngent.agent.todo_stack import TodoStack
state.todo_stack = TodoStack()
_ = state.todo_stack.push_group(["alpha", "beta"])
ids = complete_slash_args(state, "/todos", "t", prior_args=["done"])
assert "t1" in ids
assert "t2" in ids
state.remember_session_ids([3, 12, 40])
assert complete_slash_args(state, "/resume", "1") == ["12"]
assert complete_slash_args(state, "/delete", "4") == ["40"]
+103 -4
View File
@@ -109,9 +109,11 @@ async def test_help_command_usage_line(state: ReplState, capsys: pytest.CaptureF
async def test_help_history_no_fake_options(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
assert await handle_slash(state, "/help history") is True
out = capsys.readouterr().out
assert "Usage: /history [N]" in out
assert "Options:" not in out
assert "--help" not in out
assert "Usage: /history" in out
# Real flags for full/preview are documented; Click's meta --help is not.
assert "--full" in out
assert "--preview" in out
assert " --help" not in out
async def test_help_stream_clearer(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
@@ -232,6 +234,13 @@ async def test_provider_switch_prompts_when_model_missing(
state.model = "only-a"
state.rebuild_client()
# No real GET /models — catalog is config-only for unit tests.
async def _no_remote(*, refresh: bool = False) -> list[str]:
del refresh
return []
monkeypatch.setattr(state, "ensure_remote_models", _no_remote)
# When switching to b, only-a is missing → select_model is invoked interactively.
monkeypatch.setattr(
"plyngent.cli.slash.select_model",
@@ -248,7 +257,10 @@ async def test_provider_switch_prompts_when_model_missing(
assert row.model == "only-b"
async def test_provider_switch_keeps_shared_model(state: ReplState) -> None:
async def test_provider_switch_keeps_shared_model(
state: ReplState,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from plyngent.config.models import ModelConfig, OpenAICompatibleProvider, OpenAIProvider
state.config.providers = {
@@ -266,6 +278,12 @@ async def test_provider_switch_keeps_shared_model(state: ReplState) -> None:
state.provider = state.config.providers["a"]
state.model = "shared"
state.rebuild_client()
async def _no_remote(*, refresh: bool = False) -> list[str]:
del refresh
return []
monkeypatch.setattr(state, "ensure_remote_models", _no_remote)
assert await handle_slash(state, "/provider b") is True
assert state.provider_name == "b"
assert state.model == "shared"
@@ -341,6 +359,36 @@ async def test_verbose_toggle(state: ReplState) -> None:
assert get_verbose_tool_results() is False
async def test_model_persist_writes_config(
state: ReplState, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
# Seed a TOML-backed store so write() has a real path.
path = tmp_path / "plyngent.toml"
_ = path.write_text(
"""
[providers.local]
preset = "openai"
access_key_or_token = "sk-test"
[providers.local.models.gpt-test]
""",
encoding="utf-8",
)
from plyngent.config import load as load_config
state.config = load_config(path)
state.provider_name = "local"
state.provider = state.config.providers["local"]
state.model = "gpt-new-id"
assert await handle_slash(state, "/model --persist") is True
out = capsys.readouterr().out
assert "persisted model" in out
assert "gpt-new-id" in out
state.config.reload()
assert "gpt-new-id" in state.config.providers["local"].models
assert "gpt-test" in state.config.providers["local"].models
async def test_yolo_toggle(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
assert state.effective_yolo() == "off"
assert state.soft_confirm_enabled() is True
@@ -414,10 +462,61 @@ async def test_history(state: ReplState, capsys: pytest.CaptureFixture[str]) ->
]
assert await handle_slash(state, "/history") is True
out = capsys.readouterr().out
assert "mode=preview" in out
assert "user: hello" in out
assert "assistant: hi there" in out
async def test_history_last_full(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage
long_body = "# Title\n\n" + ("paragraph text. " * 20)
state.agent.messages = [
UserChatMessage(content="hello"),
AssistantChatMessage(content=long_body),
]
assert await handle_slash(state, "/history last") is True
out = capsys.readouterr().out
assert "mode=full" in out
assert "assistant:" in out
# Full mode should not use the 200-char preview ellipsis on the body alone
assert long_body[:50] in out or "Title" in out
async def test_history_one_full(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
from plyngent.lmproto.openai_compatible.model import UserChatMessage
state.agent.messages = [UserChatMessage(content="only me")]
assert await handle_slash(state, "/history 1") is True
out = capsys.readouterr().out
assert "mode=full" in out
assert "only me" in out
async def test_history_one_preview_flag(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
from plyngent.lmproto.openai_compatible.model import UserChatMessage
state.agent.messages = [UserChatMessage(content="x" * 250)]
assert await handle_slash(state, "/history 1 --preview") is True
out = capsys.readouterr().out
assert "mode=preview" in out
assert "" in out
async def test_history_full_flag(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage
body = "full " * 100
state.agent.messages = [
UserChatMessage(content="u"),
AssistantChatMessage(content=body),
]
assert await handle_slash(state, "/history 2 --full") is True
out = capsys.readouterr().out
assert "mode=full" in out
assert body[:40] in out
async def test_rounds(state: ReplState) -> None:
assert await handle_slash(state, "/rounds 40") is True
assert state.max_rounds == 40
+33
View File
@@ -179,6 +179,39 @@ def test_write_new_config() -> None:
assert config.providers["foo2"].access_key_or_token == "sk-00301212"
def test_ensure_model_and_merge_models(tmp_path: Path) -> None:
path = tmp_path / "models-persist.toml"
_ = path.write_text(
"""
[providers.local]
preset = "openai-compatible"
access_key_or_token = "sk-test"
url = "https://example/v1"
[providers.local.models.base]
""",
encoding="utf-8",
)
config = plyngent.config.load(path)
assert "base" in config.providers["local"].models
assert "extra" not in config.providers["local"].models
provider = config.ensure_model("local", "extra")
assert "extra" in provider.models
assert "base" in provider.models
# idempotent
_ = config.ensure_model("local", "extra")
config.write()
config.reload()
assert set(config.providers["local"].models) == {"base", "extra"}
assert config.providers["local"].access_key_or_token == "sk-test"
_ = config.merge_models("local", ["extra", "remote-a", "remote-b"])
config.write()
config.reload()
assert set(config.providers["local"].models) == {"base", "extra", "remote-a", "remote-b"}
def test_update_config() -> None:
file = Path(__file__).parent / "plyngent-edit-2.toml"
_ = shutil.copy(Path(__file__).parent / "plyngent-valid.toml", file)
+23 -1
View File
@@ -18,9 +18,16 @@ from plyngent.prompting import (
class ScriptedBackend:
"""Deterministic backend for unit tests."""
def __init__(self, lines: list[str], *, confirms: list[bool] | None = None) -> None:
def __init__(
self,
lines: list[str],
*,
confirms: list[bool] | None = None,
secrets: list[str] | None = None,
) -> None:
self.lines = list(lines)
self.confirms = list(confirms or [])
self.secrets = list(secrets or [])
self.interactive = True
self.echoes: list[str] = []
@@ -42,6 +49,13 @@ class ScriptedBackend:
msg = "no scripted lines left"
raise NonInteractiveError(msg)
def read_secret_line(self, prompt: str) -> str:
del prompt
if self.secrets:
return self.secrets.pop(0)
msg = "no scripted secrets left"
raise NonInteractiveError(msg)
def confirm(self, prompt: str, *, default: bool = False) -> bool:
del prompt
if self.confirms:
@@ -63,6 +77,14 @@ def test_ask_free_text() -> None:
assert ask("Name?") == "hello world"
def test_ask_secret() -> None:
from plyngent.prompting import ask_secret
backend = ScriptedBackend([], secrets=["s3cret"])
with temporary_backend(backend):
assert ask_secret("Password?") == "s3cret"
def test_ask_default_when_empty_uses_backend_default() -> None:
backend = ScriptedBackend([])
with temporary_backend(backend):
+7 -1
View File
@@ -4,7 +4,11 @@ from typing import TYPE_CHECKING
import pytest
from plyngent.tools.workspace import clear_workspace_root, set_workspace_root
from plyngent.tools.workspace import (
clear_workspace_allowlist,
clear_workspace_root,
set_workspace_root,
)
if TYPE_CHECKING:
from collections.abc import Iterator
@@ -14,6 +18,8 @@ if TYPE_CHECKING:
@pytest.fixture
def workspace(tmp_path: Path) -> Iterator[Path]:
clear_workspace_root()
clear_workspace_allowlist()
root = set_workspace_root(tmp_path)
yield root
clear_workspace_allowlist()
clear_workspace_root()
+112 -7
View File
@@ -11,18 +11,123 @@ def test_classify_delete_and_move() -> None:
assert "move" in (classify_danger("move_path", {"src": "a", "dst": "b"}) or "")
def test_classify_copy_overwrite_only() -> None:
def test_classify_copy() -> None:
# Without overwrite flag, copy is not soft-confirmed.
assert classify_danger("copy_path", {"src": "a", "dst": "b"}) is None
assert "overwrite" in (classify_danger("copy_path", {"src": "a", "dst": "b", "overwrite": True}) or "")
assert classify_danger("copy_path", {"src": "a", "dst": "b", "overwrite": False}) is None
def test_classify_write_file_overwrite(workspace: object) -> None:
assert isinstance(workspace, Path)
_ = (workspace / "x.txt").write_text("old", encoding="utf-8")
assert "overwrite" in (classify_danger("write_file", {"path": "x.txt", "content": "n"}) or "")
assert classify_danger("write_file", {"path": "new.txt", "content": "n"}) is None
def test_classify_write_overwrite_only(tmp_path: Path) -> None:
from plyngent.tools.workspace import clear_workspace_root, set_workspace_root
set_workspace_root(tmp_path)
try:
# New file: no soft-confirm
assert classify_danger("write_file", {"path": "new.txt"}) is None
# Partial edits: never soft-confirm
assert classify_danger("edit_replace", {"path": "x.txt"}) is None
assert classify_danger("edit_lineno", {"path": "x.txt", "start_line": 1, "end_line": 2}) is None
# Existing file write: confirm total overwrite
(tmp_path / "x.txt").write_text("old", encoding="utf-8")
reason = classify_danger("write_file", {"path": "x.txt"})
assert reason is not None and "overwrite" in reason
(tmp_path / "dst.txt").write_text("d", encoding="utf-8")
(tmp_path / "src.txt").write_text("s", encoding="utf-8")
assert classify_danger("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": False}) is None
creason = classify_danger("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": True})
assert creason is not None and "overwrite" in creason
finally:
clear_workspace_root()
def test_classify_safe_tools() -> None:
assert classify_danger("read_file", {"path": "a"}) is None
assert classify_danger("listdir", {"path": "."}) is None
assert classify_danger("run_command", {"command": ["echo", "hi"]}) is None
assert classify_danger("open_pty", {"command": ["true"]}) is None
assert classify_danger("run_command", {"command": ["ls", "-la"]}) is None
def test_classify_run_command_batch_risky() -> None:
reason = classify_danger(
"run_command_batch",
{
"commands": [
{"command": ["echo", "ok"]},
{"command": ["bash", "-c", "echo risky"]},
]
},
)
assert reason is not None
assert "run_command_batch" in reason
assert "risky" in reason or "bash" in reason
assert (
classify_danger(
"run_command_batch",
{"commands": [{"command": ["echo", "ok"]}]},
)
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
def test_shell_confirm_formats_command_placeholder() -> None:
script = chr(10).join(["line1", "line2", "line3"])
reason = classify_danger(
"run_command",
{"command": ["bash", "-c", script]},
)
assert reason is not None
assert "$(command)" in reason
assert "line1" in reason and "line2" in reason and "line3" in reason
argv_line = next(ln for ln in reason.splitlines() if "argv:" in ln)
assert "line1" not in argv_line
lines = reason.splitlines()
idx = lines.index(" command:")
body = lines[idx + 1 :]
assert body
assert all(ln.startswith(" ") for ln in body)
+38 -1
View File
@@ -19,8 +19,33 @@ def test_write_read_listdir_edit(workspace: object) -> None:
def test_edit_replace_first_only(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "t.txt", "aa aa")
_ = call_sync(edit_replace, "t.txt", "aa", "bb")
result = call_sync(edit_replace, "t.txt", "aa", "bb")
assert call_sync(read_file, "t.txt") == "bb aa"
assert "1 of 2" in result or "1 of 2 matches" in result
assert "remain" in result
def test_edit_replace_max_replaces(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "t.txt", "aa aa aa")
result = call_sync(edit_replace, "t.txt", "aa", "bb", max_replaces=2)
assert call_sync(read_file, "t.txt") == "bb bb aa"
assert "2 of 3" in result
assert "1 remain" in result
def test_edit_replace_all_matches(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "t.txt", "aa aa")
result = call_sync(edit_replace, "t.txt", "aa", "bb", max_replaces=10)
assert call_sync(read_file, "t.txt") == "bb bb"
assert "all 2 matches" in result
def test_edit_replace_max_replaces_invalid(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "t.txt", "aa")
assert "max_replaces" in call_sync(edit_replace, "t.txt", "aa", "bb", max_replaces=0)
def test_edit_missing_old_string(workspace: object) -> None:
@@ -35,6 +60,18 @@ def test_read_offset_limit(workspace: object) -> None:
assert call_sync(read_file, "lines.txt", offset=1, limit=2) == "b\nc\n"
def test_read_with_lineno(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "num.txt", "a\nb\nc\n")
out = call_sync(read_file, "num.txt", with_lineno=True)
assert " 1|a\n" in out
assert " 2|b\n" in out
assert " 3|c\n" in out
# offset is 0-based; line numbers stay absolute 1-based file lines
mid = call_sync(read_file, "num.txt", offset=1, limit=1, with_lineno=True)
assert mid == " 2|b\n"
def test_listdir_missing(workspace: object) -> None:
del workspace
assert "error" in call_sync(listdir, "nope")
+254 -1
View File
@@ -4,7 +4,16 @@ import asyncio
import sys
from pathlib import Path
from plyngent.tools.process import close_pty, open_pty, read_pty, run_command, write_pty
from plyngent.tools.process import (
ask_into_pty,
close_pty,
open_pty,
read_pty,
run_command,
run_command_batch,
write_pty,
write_pty_keys,
)
from plyngent.tools.process.pty_session import PtyManager
from plyngent.tools.workspace import set_command_denylist
from tests.test_tools.helpers import call_async, call_sync
@@ -39,6 +48,85 @@ async def test_run_command_echo(workspace: object) -> None:
assert "hi" in out
async def test_run_command_batch_serial(workspace: object) -> None:
del workspace
out = await call_async(
run_command_batch,
[
{"command": _py("print('a')")},
{"command": _py("print('b')")},
],
)
assert "steps=2" in out
assert "ran=2" in out
assert "stopped_early=false" in out
assert "--- step 0 ---" in out
assert "--- step 1 ---" in out
assert "a" in out and "b" in out
async def test_run_command_batch_stop_on_error(workspace: object) -> None:
del workspace
out = await call_async(
run_command_batch,
[
{"command": _py("import sys; sys.exit(2)")},
{"command": _py("print('should-not-run')")},
],
stop_on_error=True,
)
assert "ran=1" in out
assert "stopped_early=true" in out
assert "should-not-run" not in out
async def test_run_command_batch_pipe_out(workspace: object) -> None:
del workspace
out = await call_async(
run_command_batch,
[
{
"command": _py("print('piped-payload', end='')"),
"pipe_out": True,
},
{
"command": _py("import sys; print(sys.stdin.read())"),
},
],
)
assert "ran=2" in out
assert "piped-payload" in out
assert "pipe_out=true" in out
async def test_run_command_batch_last_pipe_out_error(workspace: object) -> None:
del workspace
out = await call_async(
run_command_batch,
[{"command": _py("print(1)"), "pipe_out": True}],
)
assert "error:" in out
assert "pipe_out" in out
async def test_run_command_batch_mix_stderr(workspace: object) -> None:
del workspace
out = await call_async(
run_command_batch,
[
{
"command": _py("import sys; sys.stdout.write('out'); sys.stderr.write('err')"),
"mix_stderr": True,
"pipe_out": True,
},
{"command": _py("import sys; print(repr(sys.stdin.read()))")},
],
)
assert "ran=2" in out
# Merged capture should include both (order depends on OS scheduling).
assert "out" in out and "err" in out
async def test_run_command_denied(workspace: object) -> None:
del workspace
out = await call_async(run_command, ["rm", "-rf", "."])
@@ -161,6 +249,92 @@ def test_write_pty_unknown_session(workspace: object) -> None:
assert "error" in call_sync(write_pty, 999_999, "x")
async def test_ask_into_pty_writes_without_leaking_secret(workspace: object) -> None:
"""Human answer goes to PTY only; tool result must not contain the secret."""
del workspace
from plyngent.prompting import temporary_backend
from tests.test_prompting import ScriptedBackend
secret = "super-secret-password-xyz"
backend = ScriptedBackend([], secrets=[secret])
try:
opened = call_sync(
open_pty,
_py(
"import sys\n"
"while True:\n"
" line = sys.stdin.readline()\n"
" if not line:\n"
" break\n"
" sys.stdout.write(line)\n"
" sys.stdout.flush()\n"
),
)
session_id = _session_id(opened)
with temporary_backend(backend):
result = await call_async(
ask_into_pty,
session_id,
"Enter password",
secret=True,
)
assert "error:" not in result
assert "wrote=true" in result
assert "secret=true" in result
assert "source=human" in result
assert secret not in result
# Length of secret must not appear as wrote=N either.
assert f"wrote={len(secret)}" not in result
assert f"wrote={len(secret) + 1}" not in result
echoed = await call_async(read_pty, session_id, timeout=2.0, until=secret)
assert secret in echoed
closed = await call_async(close_pty, session_id)
assert "session_id=" in closed
finally:
PtyManager.close_all()
async def test_ask_into_pty_empty_cancels(workspace: object) -> None:
del workspace
from plyngent.prompting import temporary_backend
from tests.test_prompting import ScriptedBackend
backend = ScriptedBackend([], secrets=[""])
try:
opened = call_sync(open_pty, _py("import time; time.sleep(30)"))
session_id = _session_id(opened)
with temporary_backend(backend):
result = await call_async(ask_into_pty, session_id, "Pass?", secret=True)
assert "error:" in result
assert "empty" in result.lower() or "cancel" in result.lower()
finally:
PtyManager.close_all()
async def test_ask_into_pty_nonsecret_line(workspace: object) -> None:
del workspace
from plyngent.prompting import temporary_backend
from tests.test_prompting import ScriptedBackend
backend = ScriptedBackend(["yes"])
try:
opened = call_sync(
open_pty,
_py("import sys\nline = sys.stdin.readline()\nsys.stdout.write(line)\nsys.stdout.flush()\n"),
)
session_id = _session_id(opened)
with temporary_backend(backend):
result = await call_async(ask_into_pty, session_id, "Continue?", secret=False)
assert "wrote=true" in result
assert "secret=false" in result
assert "yes" not in result # answer not in tool result even for non-secret
text = await call_async(read_pty, session_id, timeout=2.0, until="yes")
assert "yes" in text
finally:
PtyManager.close_all()
async def test_pty_exec_failure_surfaces(workspace: object) -> None:
del workspace
try:
@@ -289,6 +463,85 @@ def test_pty_master_not_inheritable(workspace: object) -> None:
PtyManager.close_all()
def test_decode_write_data_escapes() -> None:
from plyngent.tools.process.pty_terminal import decode_write_data
assert decode_write_data(r"\x0f") == "\x0f"
assert decode_write_data("ctrl+x") == "\x18"
assert decode_write_data("CTRL+O") == "\x0f"
assert decode_write_data(r"\e") == "\x1b"
assert decode_write_data("key=esc") == "\x1b"
assert decode_write_data("key=enter") == "\r"
assert decode_write_data(r"a\nb") == "a\nb"
assert decode_write_data("plain") == "plain"
def test_sanitize_pty_output_escapes_csi() -> None:
from plyngent.tools.process.pty_terminal import sanitize_pty_output_for_tool
raw = chr(0x1B) + "[?1049hhello" + chr(0x1B) + "[0m" + chr(7)
safe = sanitize_pty_output_for_tool(raw)
assert chr(0x1B) not in safe
assert "\\x1b" in safe
assert "hello" in safe
assert "\\x07" in safe
def test_close_all_empty_is_noop() -> None:
"""Ctrl-D / chat exit with no sessions must not touch the host TTY."""
PtyManager.close_all()
async def test_read_pty_sanitizes_esc(workspace: object) -> None:
del workspace
try:
opened = call_sync(open_pty, _py("print(chr(0x1B)+'[31mred'+chr(0x1B)+'[0m')"))
session_id = _session_id(opened)
text = await call_async(read_pty, session_id, timeout=2.0, until="red")
assert "red" in text
payload = text.split("--- data ---", 1)[-1]
assert chr(0x1B) not in payload
assert "\\x1b" in payload or "red" in payload
_ = await call_async(close_pty, session_id)
finally:
PtyManager.close_all()
async def test_write_pty_keys_ctrl_escape(workspace: object) -> None:
del workspace
try:
opened = call_sync(
open_pty,
_py(
"import sys\n"
"data = sys.stdin.buffer.read(1)\n"
"sys.stdout.buffer.write(data)\n"
"sys.stdout.buffer.flush()\n"
),
)
session_id = _session_id(opened)
written = call_sync(write_pty_keys, session_id, "ctrl+c")
assert "wrote=1" in written
text = await call_async(read_pty, session_id, timeout=2.0)
assert "error" not in text.lower() or "alive=" in text
_ = await call_async(close_pty, session_id)
finally:
PtyManager.close_all()
def test_write_pty_is_literal() -> None:
"""Plain write_pty must not call the keys decoder."""
import inspect
from plyngent.tools.process.pty_terminal import decode_write_data
assert decode_write_data("press ctrl+c to cancel") == "press " + chr(3) + " to cancel"
src = inspect.getsource(write_pty.handler)
assert "decode_write_data" not in src
doc = write_pty.description or write_pty.handler.__doc__ or ""
assert "literal" in doc.lower()
def test_pty_backend_available() -> None:
from plyngent.tools.process.pty_backend import pty_available
+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
assert "max_depth" in call_sync(tree, ".", max_depth=0)
assert "max_entries" in call_sync(tree, ".", max_entries=0)
def test_tree_default_noise_dirs(workspace: object) -> None:
assert isinstance(workspace, Path)
(workspace / "src").mkdir()
_ = (workspace / "src" / "app.py").write_text("x", encoding="utf-8")
(workspace / "node_modules").mkdir()
_ = (workspace / "node_modules" / "pkg.js").write_text("x", encoding="utf-8")
(workspace / "__pycache__").mkdir()
_ = (workspace / "__pycache__" / "x.pyc").write_text("x", encoding="utf-8")
out = call_sync(tree, ".")
assert "src/" in out
assert "app.py" in out
assert "node_modules" not in out
assert "__pycache__" not in out
def test_tree_skip_dirs_empty_shows_noise(workspace: object) -> None:
assert isinstance(workspace, Path)
(workspace / "node_modules").mkdir()
_ = (workspace / "node_modules" / "pkg.js").write_text("x", encoding="utf-8")
out = call_sync(tree, ".", skip_dirs=[])
assert "node_modules/" in out
assert "pkg.js" in out
def test_tree_skip_dirs_custom(workspace: object) -> None:
assert isinstance(workspace, Path)
(workspace / "keep_me").mkdir()
_ = (workspace / "keep_me" / "a.txt").write_text("x", encoding="utf-8")
(workspace / "drop_me").mkdir()
_ = (workspace / "drop_me" / "b.txt").write_text("x", encoding="utf-8")
# Custom list replaces default noise set (node_modules not in list → would show if present).
out = call_sync(tree, ".", skip_dirs=["drop_me"])
assert "keep_me/" in out
assert "drop_me" not in out
def test_tree_path_denylist_walk(workspace: object) -> None:
from plyngent.tools.workspace import set_path_denylist
assert isinstance(workspace, Path)
(workspace / "ok").mkdir()
_ = (workspace / "ok" / "a.txt").write_text("x", encoding="utf-8")
(workspace / "secrets").mkdir()
_ = (workspace / "secrets" / "key.txt").write_text("x", encoding="utf-8")
set_path_denylist(["/secrets"])
try:
out = call_sync(tree, ".")
assert "ok/" in out
assert "secrets" not in out
out2 = call_sync(tree, ".", apply_path_denylist=False, skip_dirs=[])
assert "secrets/" in out2
finally:
set_path_denylist(None)
+79
View File
@@ -43,12 +43,91 @@ def test_path_denylist(workspace: object) -> None:
def test_command_denylist(workspace: object) -> None:
del workspace
from plyngent.tools.workspace import (
clear_policy_allowed_commands,
set_policy_confirm_hook,
)
set_policy_confirm_hook(None)
clear_policy_allowed_commands()
with pytest.raises(WorkspaceError, match="basename 'rm' is blocked"):
check_command_allowed(["rm", "-rf", "/"])
check_command_allowed(["echo", "ok"])
set_command_denylist(None)
def test_command_denylist_policy_confirm_allow(workspace: object) -> None:
del workspace
from plyngent.tools.workspace import (
clear_policy_allowed_commands,
set_policy_confirm_hook,
)
calls: list[tuple[str, float]] = []
def hook(basename: str, argv: object, timeout: float) -> bool:
del argv
calls.append((basename, timeout))
return basename == "sudo"
set_policy_confirm_hook(hook)
clear_policy_allowed_commands()
try:
check_command_allowed(["sudo", "echo", "test"])
assert calls == [("sudo", 30.0)]
# Session grant: second call does not re-prompt.
check_command_allowed(["sudo", "id"])
assert len(calls) == 1
finally:
set_policy_confirm_hook(None)
clear_policy_allowed_commands()
def test_command_denylist_policy_confirm_deny(workspace: object) -> None:
del workspace
from plyngent.tools.workspace import (
clear_policy_allowed_commands,
set_policy_confirm_hook,
)
set_policy_confirm_hook(lambda *_a: False)
clear_policy_allowed_commands()
try:
with pytest.raises(WorkspaceError, match="declined or timed out"):
check_command_allowed(["sudo", "echo", "x"])
finally:
set_policy_confirm_hook(None)
clear_policy_allowed_commands()
def test_command_denylist_policy_confirm_timeout_value(workspace: object) -> None:
del workspace
from plyngent.tools.workspace import (
clear_policy_allowed_commands,
set_policy_confirm_hook,
set_policy_confirm_timeout,
)
seen: list[float] = []
def hook(basename: str, argv: object, timeout: float) -> bool:
del basename, argv
seen.append(timeout)
return False
set_policy_confirm_timeout(5.0)
set_policy_confirm_hook(hook)
clear_policy_allowed_commands()
try:
with pytest.raises(WorkspaceError):
check_command_allowed(["rm", "x"])
assert seen == [5.0]
finally:
set_policy_confirm_timeout(30.0)
set_policy_confirm_hook(None)
clear_policy_allowed_commands()
def test_root_required() -> None:
clear_workspace_root()
with pytest.raises(WorkspaceError, match="not set"):