mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
Compare commits
57 Commits
6a1160f1e3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 614ffaaf8f | |||
| 7bdd9ff8b7 | |||
| bec14e76af | |||
| 59017fd61b | |||
| 2e6cb3834a | |||
| f6d14ed549 | |||
| a4ab11c9a2 | |||
| 0d783b6d96 | |||
| d6616d0da3 | |||
| a677e06af4 | |||
| aadcd237db | |||
| 58ff646034 | |||
| d528f65c4c | |||
| 0ec0d542e8 | |||
| 182546d60e | |||
| e1e4df0f3f | |||
| a54fa534f3 | |||
| fa9f28687f | |||
| 95f277eda7 | |||
| f13ea9f316 | |||
| f51d11d46f | |||
| 5b51de12e0 | |||
| d473cd2508 | |||
| badcb3c9f3 | |||
| fc74b271bd | |||
| 1012894f0d | |||
| 020e9215bf | |||
| de79dec96f | |||
| b1a8c23582 | |||
| 9a9c808ea0 | |||
| edd54a427c | |||
| 896dc98d20 | |||
| c8a0aeda8d | |||
| 29230af911 | |||
| 4ff03d8135 | |||
| a7166d863b | |||
| 9b38a3b333 | |||
| 39f9a7f636 | |||
| 7f49c47105 | |||
| c95d1df028 | |||
| fb5b9f187b | |||
| abcb345c0d | |||
| 596af1ea1b | |||
| 57236937eb | |||
| 96cdd98e62 | |||
| 2368e46efd | |||
| 65d248064e | |||
| e04fda4bc6 | |||
| a282231082 | |||
| 844220e013 | |||
| 61c1cb2d70 | |||
| f7a562740e | |||
| 39acd5967e | |||
| b8ed9e0ee9 | |||
| 8ebe68b3af | |||
| 18008de7f1 | |||
| 2761877460 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -12,12 +12,20 @@ Plyngent is an LLM chat and agent toolkit (Python 3.14+, PDM-managed). Single-us
|
||||
pdm install # first-time dependency setup
|
||||
pdm sync # sync after pulling changes
|
||||
|
||||
pdm run basedpyright . # type checking (basedpyright, "recommended" strictness)
|
||||
pdm run ruff check . # linting
|
||||
pdm run ruff format . # formatting
|
||||
pdm run pytest # tests (pytest-asyncio auto mode)
|
||||
pdm run ruff check . # linting
|
||||
pdm run ruff format . # apply formatting
|
||||
pdm run ruff format --check . # CI: fail if unformatted (do not skip)
|
||||
pdm run basedpyright . # type checking (basedpyright, "recommended" strictness)
|
||||
pdm run pytest # tests (pytest-asyncio auto mode)
|
||||
|
||||
# Commit gateway (prek — https://prek.j178.dev/): ruff check/format + basedpyright
|
||||
uv tool install prek # once
|
||||
prek install # wire git pre-commit hook (once per clone)
|
||||
prek run --all-files # run all hooks on demand
|
||||
```
|
||||
|
||||
GitHub Actions runs `ruff check`, `ruff format --check`, `basedpyright`, then `pytest`. Local commits run the same checks via `prek.toml` so `ruff format` is not forgotten.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data modeling: `msgspec.Struct`
|
||||
@@ -70,15 +78,16 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
||||
|
||||
Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
||||
|
||||
- **`workspace`**: path resolve under root; path substring denylist; command basename denylist; clearer deny messages.
|
||||
- **`file`**: `read_file`, `write_file`, `listdir`, `tree`, `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace`, `edit_lineno` (1-based range), `copy_path` / `move_path` / `delete_path`.
|
||||
- **`process`**: `run_command` (argv, no shell, timeout, optional stdin/env); PTY `open_pty` / `read_pty` / `write_pty` / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
|
||||
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)`; session limit/idle TTL/output budget; close terminate→kill.
|
||||
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots; path substring denylist; command basename denylist (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`.
|
||||
- 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`, `choose_user`, `form_user` (shared `prompting` core).
|
||||
- **`DEFAULT_TOOLS`**: file + process + vcs + chat tool list for a `ToolRegistry`.
|
||||
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core).
|
||||
- **`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`)
|
||||
|
||||
@@ -88,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`, `--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`.
|
||||
- **`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
|
||||
@@ -112,7 +121,7 @@ Basedpyright `recommended`. Ruff includes `ANN` (private return types `ANN202` i
|
||||
- **Phase G (CLI polish + hardening)** — single-user only; multi-tenant stays Phase H.
|
||||
|
||||
**Done**
|
||||
- G0: `prompting` (`ask`/`choose`/`form`/`confirm`) + chat tools (`ask_user`/`choose_user`/`form_user`)
|
||||
- G0: `prompting` (`ask`/`choose`/`form`/`confirm`) + chat tools (`ask_user_line`/`ask_user_choice`/`ask_user_form`)
|
||||
- G1: `ReasoningDeltaEvent`, `/stream`, `/verbose`
|
||||
- G2: `/rename`, `/delete` (confirm), `/export md|json`
|
||||
- G2.5: Click slash registry (`cli/slash.py`) + `awaitlet` for sync Click / async memory; auto `/help`; completer from `slash.list_commands`
|
||||
|
||||
@@ -62,21 +62,31 @@ pdm sync # after pull
|
||||
pdm run plyngent --help
|
||||
```
|
||||
|
||||
Dev checks:
|
||||
Dev checks (same order as CI):
|
||||
|
||||
```bash
|
||||
pdm run basedpyright .
|
||||
pdm run ruff check .
|
||||
pdm run ruff format .
|
||||
pdm run ruff format --check . # or: pdm run ruff format . to apply
|
||||
pdm run basedpyright .
|
||||
pdm run pytest
|
||||
```
|
||||
|
||||
**Commit gateway** ([prek](https://prek.j178.dev/)): runs ruff check + format and basedpyright on `git commit` so format is not forgotten.
|
||||
|
||||
```bash
|
||||
uv tool install prek # once
|
||||
prek install # once per clone (installs .git/hooks/pre-commit)
|
||||
prek run --all-files # run all hooks on demand
|
||||
```
|
||||
|
||||
Config: `prek.toml`. CI still runs the same checks in GitHub Actions.
|
||||
|
||||
## Basic usage
|
||||
|
||||
```bash
|
||||
# 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]
|
||||
@@ -101,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:
|
||||
@@ -130,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
|
||||
|
||||
@@ -152,7 +162,7 @@ plyngent chat --session 3
|
||||
| `--max-rounds` | Tool-loop rounds per turn (default 32) |
|
||||
| `--stream` / `--no-stream` | Streaming deltas (default on) |
|
||||
| `--quiet` | Less status on stderr |
|
||||
| `--yes` | Allow destructive tools without confirm (also for one-shot) |
|
||||
| `--yes` | YOLO on: skip destructive-tool confirms for this process |
|
||||
| `--log-level` | On the root CLI: `DEBUG`, `INFO`, `WARNING`, … |
|
||||
|
||||
Sessions resume the **most recently updated** session for the current workspace unless you pass `--new` or `--session`. Each session remembers the last **provider** and **model** (restored on resume so you are not re-prompted).
|
||||
@@ -176,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
|
||||
@@ -186,17 +196,22 @@ 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) |
|
||||
| `/compact` | Soft-compact + model summary into a **new** session |
|
||||
| `/stream` `/verbose` `/markdown` `/tools` `/rounds` | Toggles and limits |
|
||||
| `/yolo [on\|off\|once]` | Soft destructive confirms: sticky skip, off, or next turn only |
|
||||
|
||||
| `/retry` | Re-run incomplete last user turn (after error/cancel) |
|
||||
| `/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.
|
||||
@@ -209,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` / `choose_user` / `form_user`).
|
||||
Default registry: file ops (including `tree` with default noise-dir skips), `run_command` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
|
||||
|
||||
Safety defaults:
|
||||
|
||||
- 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).
|
||||
- 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)
|
||||
|
||||
|
||||
@@ -45,7 +45,10 @@ Then for every projects, check the dev dependency group where you can find tools
|
||||
|
||||
Type checking: `pdm run basedpyright .`
|
||||
Linting: `pdm run ruff check .`
|
||||
Formating: `pdm run ruff format .`
|
||||
Formatting (apply): `pdm run ruff format .`
|
||||
Formatting (CI check): `pdm run ruff format --check .`
|
||||
|
||||
CI (`.github/workflows/ci.yml`) runs lint, **format check**, typecheck, then tests. Always run `pdm run ruff format .` before push so `ruff format --check` does not fail on `main` after a release tag has already published.
|
||||
|
||||
<!-- Contents in comments are for projects with long-period developments, but this is a new project.
|
||||
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "plyngent"
|
||||
version = "0.1.0"
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -29,10 +29,10 @@ from plyngent.lmproto.openai_compatible.model import (
|
||||
ChatCompletionsParam,
|
||||
ChunkChoice,
|
||||
DeltaMessage,
|
||||
DeveloperChatMessage,
|
||||
StreamFunctionDelta,
|
||||
StreamToolCallDelta,
|
||||
SystemChatMessage,
|
||||
ToolChatMessage,
|
||||
ToolFunctionItem,
|
||||
UserChatMessage,
|
||||
)
|
||||
@@ -97,21 +97,22 @@ def chat_messages_to_responses_input(
|
||||
if isinstance(message, SystemChatMessage):
|
||||
if message.content.strip():
|
||||
instructions_parts.append(message.content)
|
||||
elif isinstance(message, DeveloperChatMessage):
|
||||
# Keep mid-turn control as input developer messages (not folded into instructions).
|
||||
if message.content.strip():
|
||||
items.append(ResponseEasyInputMessage(role="developer", content=message.content))
|
||||
elif isinstance(message, UserChatMessage):
|
||||
items.append(ResponseEasyInputMessage(role="user", content=message.content))
|
||||
elif isinstance(message, AssistantChatMessage):
|
||||
items.extend(_assistant_to_input_items(message))
|
||||
elif isinstance(message, ToolChatMessage):
|
||||
else:
|
||||
# ToolChatMessage (remaining AnyChatMessage arm)
|
||||
items.append(
|
||||
ResponseFunctionToolCallOutput(
|
||||
call_id=message.tool_call_id,
|
||||
output=message.content,
|
||||
)
|
||||
)
|
||||
else:
|
||||
content = getattr(message, "content", None)
|
||||
if isinstance(content, str) and content:
|
||||
items.append(ResponseEasyInputMessage(role="user", content=content))
|
||||
|
||||
instructions = "\n\n".join(instructions_parts) if instructions_parts else None
|
||||
return instructions, items
|
||||
@@ -158,10 +159,33 @@ def _reasoning_summary_text(response: Response) -> str:
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def responses_status_to_finish_reason(
|
||||
response: Response,
|
||||
*,
|
||||
has_tool_calls: bool,
|
||||
) -> str:
|
||||
"""Map Responses ``status`` to a chat-style finish_reason for the agent loop."""
|
||||
status = response.status
|
||||
status_s = status if isinstance(status, str) else None
|
||||
if status_s == "incomplete":
|
||||
details = response.incomplete_details
|
||||
if details is not UNSET and details is not None:
|
||||
raw_reason = details.reason
|
||||
if raw_reason is not UNSET and raw_reason == "content_filter":
|
||||
return "content_filter"
|
||||
return "length"
|
||||
if status_s in {"failed", "cancelled"}:
|
||||
return status_s
|
||||
if has_tool_calls:
|
||||
return "tool_calls"
|
||||
return "stop"
|
||||
|
||||
|
||||
def response_to_chat_completion(response: Response) -> ChatCompletionResponse:
|
||||
"""Wrap Responses result as a synthetic chat completion for the agent loop."""
|
||||
assistant = response_to_assistant_message(response)
|
||||
finish: str | None = "tool_calls" if assistant.tool_calls is not UNSET else "stop"
|
||||
has_tools = assistant.tool_calls is not UNSET and bool(assistant.tool_calls)
|
||||
finish = responses_status_to_finish_reason(response, has_tool_calls=has_tools)
|
||||
usage = response.usage if response.usage is not UNSET else UNSET
|
||||
created = int(response.created_at)
|
||||
return ChatCompletionResponse(
|
||||
@@ -180,6 +204,28 @@ def response_to_chat_completion(response: Response) -> ChatCompletionResponse:
|
||||
)
|
||||
|
||||
|
||||
def finish_reason_chunk(
|
||||
*,
|
||||
model: str,
|
||||
finish_reason: str,
|
||||
created: int = 0,
|
||||
) -> ChatCompletionChunk:
|
||||
"""Terminal stream chunk carrying only ``finish_reason`` (no delta text)."""
|
||||
return ChatCompletionChunk(
|
||||
id="resp-stream",
|
||||
object="chat.completion.chunk",
|
||||
created=created,
|
||||
model=model,
|
||||
choices=[
|
||||
ChunkChoice(
|
||||
index=0,
|
||||
delta=DeltaMessage(),
|
||||
finish_reason=cast("Any", finish_reason),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def text_delta_chunk(*, model: str, content: str, created: int = 0) -> ChatCompletionChunk:
|
||||
return ChatCompletionChunk(
|
||||
id="resp-stream",
|
||||
|
||||
@@ -8,8 +8,11 @@ from msgspec import UNSET
|
||||
|
||||
from plyngent.agent.responses_bridge import (
|
||||
chat_param_to_responses_kwargs,
|
||||
finish_reason_chunk,
|
||||
reasoning_delta_chunk,
|
||||
response_to_assistant_message,
|
||||
response_to_chat_completion,
|
||||
responses_status_to_finish_reason,
|
||||
text_delta_chunk,
|
||||
tool_call_chunks_from_response,
|
||||
usage_chunk_from_response,
|
||||
@@ -109,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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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] = {
|
||||
@@ -191,6 +193,11 @@ class ToolRegistry:
|
||||
def __len__(self) -> int:
|
||||
return len(self._tools)
|
||||
|
||||
@property
|
||||
def soft_confirm(self) -> bool:
|
||||
"""True when dangerous tools are gated by ``on_confirm``."""
|
||||
return self._danger is not None and self._on_confirm is not None
|
||||
|
||||
async def _invoke(self, definition: ToolDefinition, args: dict[str, object]) -> str:
|
||||
try:
|
||||
result = definition.handler(**args)
|
||||
@@ -207,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)
|
||||
@@ -218,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)."""
|
||||
|
||||
+73
-11
@@ -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)
|
||||
|
||||
|
||||
@@ -171,13 +180,16 @@ async def _run_oneshot(state: ReplState, prompt_text: str) -> int:
|
||||
try:
|
||||
ok = await run_user_text_with_retries(state.agent, prompt_text, delays=())
|
||||
except asyncio.CancelledError:
|
||||
state.expire_yolo_once(quiet=True)
|
||||
return EXIT_CANCELLED
|
||||
except KeyboardInterrupt:
|
||||
state.expire_yolo_once(quiet=True)
|
||||
return EXIT_CANCELLED
|
||||
state.expire_yolo_once(quiet=True)
|
||||
return EXIT_OK if ok else EXIT_TURN_FAILED
|
||||
|
||||
|
||||
async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
|
||||
async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
||||
*,
|
||||
config_path: Path | None,
|
||||
provider_name: str | None,
|
||||
@@ -206,7 +218,10 @@ async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
|
||||
_warn_recoverable_providers(store.recoverable_providers)
|
||||
|
||||
_setup_workspace_and_hooks(store, workspace, interactive=interactive)
|
||||
confirm_destructive: bool | None = False if yes else None
|
||||
# --yes forces sticky YOLO; else derive from config.confirm_destructive.
|
||||
from plyngent.cli.state import YoloMode
|
||||
|
||||
yolo: YoloMode | None = "on" if yes else None
|
||||
|
||||
memory = await MemoryStore.open(_database_config(store, quiet=quiet or oneshot))
|
||||
try:
|
||||
@@ -239,8 +254,33 @@ async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
|
||||
preferred_model=preferred_model,
|
||||
interactive=interactive,
|
||||
)
|
||||
model_id = select_model(provider, preferred=preferred_model, interactive=interactive)
|
||||
_ = create_client(provider)
|
||||
# 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,
|
||||
)
|
||||
|
||||
remote_ids: list[str] | None = 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,
|
||||
preferred=preferred_model,
|
||||
interactive=interactive,
|
||||
choices=choices,
|
||||
)
|
||||
except ProviderNotSupportedError as exc:
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
|
||||
@@ -255,8 +295,13 @@ async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
|
||||
max_rounds=max_rounds,
|
||||
stream_enabled=stream,
|
||||
interactive_limits=interactive,
|
||||
confirm_destructive=confirm_destructive,
|
||||
yolo=yolo,
|
||||
)
|
||||
# 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)
|
||||
|
||||
@@ -279,8 +324,14 @@ async def _run_chat( # noqa: C901, PLR0912 — 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:
|
||||
@@ -367,7 +418,7 @@ def main(ctx: click.Context, log_level: str) -> None:
|
||||
"--yes",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Allow destructive tools without confirm (one-shot / non-interactive).",
|
||||
help="Enable YOLO: skip destructive-tool confirms (sticky for this process).",
|
||||
)
|
||||
@click.option(
|
||||
"--quiet",
|
||||
@@ -478,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__":
|
||||
|
||||
+76
-49
@@ -4,7 +4,7 @@ import contextlib
|
||||
import os
|
||||
import sys
|
||||
from contextvars import ContextVar
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import click
|
||||
|
||||
@@ -32,6 +32,8 @@ _TOOL_ARGS_PREVIEW = 80
|
||||
_verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False)
|
||||
_markdown_enabled: ContextVar[bool] = ContextVar("markdown_enabled", default=True)
|
||||
|
||||
type StreamSource = Literal["reasoning", "assistant"]
|
||||
|
||||
|
||||
def set_verbose_tool_results(enabled: bool) -> None: # noqa: FBT001
|
||||
"""Set whether tool results print in full (True) or as a short preview."""
|
||||
@@ -87,26 +89,40 @@ def _clear_streamed_lines(line_count: int) -> None:
|
||||
|
||||
|
||||
def _line_count_for_clear(label: str, body: str) -> int:
|
||||
"""Approximate terminal lines used by ``label + body`` for cursor erase."""
|
||||
"""Approximate terminal lines used by ``label\\n + body`` for cursor erase."""
|
||||
if not body and not label:
|
||||
return 0
|
||||
text = label + body
|
||||
# +1 for trailing newline after the stream block in render_events.
|
||||
# Label is on its own line; body may contain newlines.
|
||||
text = f"{label}\n{body}" if label else body
|
||||
return text.count("\n") + 1
|
||||
|
||||
|
||||
def print_markdown(text: str, *, label: str = "assistant: ") -> None:
|
||||
"""Render *text* as markdown via Rich, with a cyan label."""
|
||||
def print_markdown(text: str, *, label: str = "assistant:") -> None:
|
||||
"""Render *text* as markdown via Rich; *label* on its own line when set."""
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.text import Text
|
||||
|
||||
console = Console(file=sys.stdout, highlight=False)
|
||||
if label:
|
||||
console.print(Text(label, style="cyan"), end="")
|
||||
console.print(Text(label, style="cyan"))
|
||||
console.print(Markdown(text))
|
||||
|
||||
|
||||
def _flush_assistant_markdown(body: str, *, pretty: bool) -> None:
|
||||
"""Replace the plain assistant stream with markdown when enabled."""
|
||||
if not body.strip():
|
||||
click.echo()
|
||||
return
|
||||
if pretty:
|
||||
lines = _line_count_for_clear("assistant:", body)
|
||||
_clear_streamed_lines(lines)
|
||||
print_markdown(body, label="assistant:")
|
||||
click.echo()
|
||||
else:
|
||||
click.echo()
|
||||
|
||||
|
||||
async def render_events( # noqa: C901, PLR0912, PLR0915
|
||||
events: AsyncIterator[AgentEvent],
|
||||
*,
|
||||
@@ -115,38 +131,65 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
|
||||
) -> None:
|
||||
"""Print agent events to the terminal (text deltas stream as they arrive).
|
||||
|
||||
When markdown is enabled and stdout is a TTY, the plain streamed assistant
|
||||
text is replaced at end-of-turn with a Rich markdown render.
|
||||
Assistant and reasoning each start on a new line after their label. When the
|
||||
content source changes (reasoning ↔ assistant, or tools/errors), the
|
||||
assistant markdown buffer is flushed so streams do not mix and Rich can
|
||||
re-render completed assistant segments.
|
||||
"""
|
||||
show_full = get_verbose_tool_results() if verbose is None else verbose
|
||||
use_markdown = get_markdown_enabled() if markdown is None else markdown
|
||||
pretty = bool(use_markdown and markdown_render_available())
|
||||
|
||||
printed_reasoning = False
|
||||
printed_text = False
|
||||
source: StreamSource | None = None
|
||||
assistant_buf: list[str] = []
|
||||
# Tools/errors after assistant text: skip replace (would erase tool lines).
|
||||
interrupted_by_other = False
|
||||
printed_reasoning = False
|
||||
printed_assistant = False
|
||||
|
||||
def flush_assistant() -> None:
|
||||
nonlocal source, assistant_buf, printed_assistant
|
||||
if source != "assistant" and not assistant_buf:
|
||||
return
|
||||
body = "".join(assistant_buf)
|
||||
assistant_buf = []
|
||||
if printed_assistant:
|
||||
_flush_assistant_markdown(body, pretty=pretty)
|
||||
printed_assistant = False
|
||||
if source == "assistant":
|
||||
source = None
|
||||
|
||||
def begin_reasoning() -> None:
|
||||
nonlocal source, printed_reasoning
|
||||
if source == "reasoning":
|
||||
return
|
||||
if source == "assistant":
|
||||
flush_assistant()
|
||||
click.echo()
|
||||
click.secho("reasoning:", fg="bright_black")
|
||||
source = "reasoning"
|
||||
printed_reasoning = True
|
||||
|
||||
def begin_assistant() -> None:
|
||||
nonlocal source, printed_assistant
|
||||
if source == "assistant":
|
||||
return
|
||||
if source == "reasoning":
|
||||
click.echo() # end reasoning stream line
|
||||
source = None
|
||||
click.echo()
|
||||
click.secho("assistant:", fg="cyan")
|
||||
source = "assistant"
|
||||
printed_assistant = True
|
||||
|
||||
async for event in events:
|
||||
if isinstance(event, ReasoningDeltaEvent):
|
||||
if not printed_reasoning:
|
||||
click.echo()
|
||||
click.secho("reasoning: ", fg="bright_black", nl=False)
|
||||
printed_reasoning = True
|
||||
begin_reasoning()
|
||||
_echo_stream(event.content)
|
||||
elif isinstance(event, TextDeltaEvent):
|
||||
if printed_reasoning and not printed_text:
|
||||
click.echo()
|
||||
if not printed_text:
|
||||
click.echo()
|
||||
click.secho("assistant: ", fg="cyan", nl=False)
|
||||
printed_text = True
|
||||
begin_assistant()
|
||||
assistant_buf.append(event.content)
|
||||
_echo_stream(event.content)
|
||||
elif isinstance(event, ToolCallEvent):
|
||||
if printed_text:
|
||||
interrupted_by_other = True
|
||||
flush_assistant()
|
||||
call = event.tool_call
|
||||
if isinstance(call, AssistantFunctionToolCall):
|
||||
args = _preview(call.function.arguments, _TOOL_ARGS_PREVIEW)
|
||||
@@ -154,8 +197,7 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
|
||||
else:
|
||||
click.secho(f"\n[tool] custom id={call.id}", fg="yellow")
|
||||
elif isinstance(event, ToolResultEvent):
|
||||
if printed_text:
|
||||
interrupted_by_other = True
|
||||
flush_assistant()
|
||||
content = event.message.content
|
||||
if show_full:
|
||||
click.secho(f"[tool ok]\n{content}", fg="magenta")
|
||||
@@ -164,8 +206,7 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
|
||||
one_line = preview.replace("\n", " ")
|
||||
click.secho(f"[tool ok] {one_line}", fg="magenta")
|
||||
elif isinstance(event, ErrorEvent):
|
||||
if printed_text:
|
||||
interrupted_by_other = True
|
||||
flush_assistant()
|
||||
suffix = ""
|
||||
if event.source:
|
||||
suffix += f" source={event.source}"
|
||||
@@ -173,15 +214,13 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
|
||||
suffix += " (fatal)"
|
||||
click.secho(f"\n[error]{suffix} {event.message}", fg="bright_red")
|
||||
elif isinstance(event, CancelledEvent):
|
||||
if printed_text:
|
||||
interrupted_by_other = True
|
||||
flush_assistant()
|
||||
if event.reason:
|
||||
click.secho(f"\n[cancelled] {event.reason}", fg="yellow")
|
||||
else:
|
||||
click.secho("\n[cancelled]", fg="yellow")
|
||||
elif isinstance(event, MaxRoundsEvent):
|
||||
if printed_text:
|
||||
interrupted_by_other = True
|
||||
flush_assistant()
|
||||
if event.continued:
|
||||
click.secho(
|
||||
f"\n[max rounds {event.rounds} reached — continuing with a higher allowance]",
|
||||
@@ -195,21 +234,9 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
|
||||
# AssistantMessageEvent — text already shown via TextDeltaEvent.
|
||||
_ = event
|
||||
|
||||
full_assistant = "".join(assistant_buf)
|
||||
if pretty and printed_text and full_assistant.strip() and not interrupted_by_other:
|
||||
# Replace plain stream with markdown (assistant block only).
|
||||
lines = _line_count_for_clear("assistant: ", full_assistant)
|
||||
# Also account for blank line before label when reasoning was shown.
|
||||
if printed_reasoning:
|
||||
lines += 1
|
||||
_clear_streamed_lines(lines)
|
||||
if printed_reasoning:
|
||||
# Reasoning already printed above; leave it and only re-print assistant.
|
||||
pass
|
||||
print_markdown(full_assistant, label="assistant: ")
|
||||
click.echo()
|
||||
return
|
||||
|
||||
if printed_text or printed_reasoning:
|
||||
# End-of-turn: flush any open assistant segment; close reasoning stream.
|
||||
if assistant_buf or printed_assistant:
|
||||
flush_assistant()
|
||||
elif printed_reasoning:
|
||||
click.echo()
|
||||
click.echo()
|
||||
|
||||
+128
-39
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from typing import TYPE_CHECKING, Protocol, cast, runtime_checkable
|
||||
from typing import TYPE_CHECKING, Literal, Protocol, cast, runtime_checkable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable, Sequence
|
||||
@@ -10,6 +11,10 @@ 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"]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -25,12 +30,30 @@ def config_model_ids(provider: Provider) -> list[str]:
|
||||
def merge_model_choices(
|
||||
config_ids: Iterable[str],
|
||||
remote_ids: Iterable[str] | None = None,
|
||||
*,
|
||||
prefer: ModelListPrefer = "remote",
|
||||
) -> list[str]:
|
||||
"""Union config and remote ids (sorted, unique)."""
|
||||
merged: set[str] = {i for i in config_ids if i}
|
||||
if remote_ids is not None:
|
||||
merged.update(i for i in remote_ids if i)
|
||||
return sorted(merged)
|
||||
"""Merge config and remote model ids.
|
||||
|
||||
*prefer*:
|
||||
- ``remote`` (default): remote catalog first (sorted), then config-only ids
|
||||
- ``union``: sorted unique union
|
||||
- ``config``: config first, then remote-only ids
|
||||
"""
|
||||
config_list = [i for i in config_ids if i]
|
||||
remote_list = [i for i in (remote_ids or ()) if i]
|
||||
if not remote_list:
|
||||
return sorted(set(config_list))
|
||||
if prefer == "union":
|
||||
return sorted(set(config_list) | set(remote_list))
|
||||
remote_sorted = sorted(set(remote_list))
|
||||
config_only = sorted(set(config_list) - set(remote_sorted))
|
||||
if prefer == "remote":
|
||||
return [*remote_sorted, *config_only]
|
||||
# config first
|
||||
config_sorted = sorted(set(config_list))
|
||||
remote_only = sorted(set(remote_list) - set(config_sorted))
|
||||
return [*config_sorted, *remote_only]
|
||||
|
||||
|
||||
def client_supports_models(client: object) -> bool:
|
||||
@@ -38,25 +61,63 @@ 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,
|
||||
*,
|
||||
remote_ids: Sequence[str] | None = None,
|
||||
prefer: ModelListPrefer = "remote",
|
||||
) -> list[str]:
|
||||
"""Config plus remote catalog for selection / Tab complete."""
|
||||
return merge_model_choices(config_model_ids(provider), remote_ids)
|
||||
"""Config plus remote catalog for selection / Tab complete (remote-first)."""
|
||||
return merge_model_choices(config_model_ids(provider), remote_ids, prefer=prefer)
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -77,6 +79,27 @@ def bind_tab_complete(readline_mod: object) -> None:
|
||||
_ = parse("python:bind ^I rl_complete")
|
||||
|
||||
|
||||
def bind_utf8_input(readline_mod: object) -> None:
|
||||
"""Best-effort 8-bit / UTF-8 settings for GNU readline (CJK backspace).
|
||||
|
||||
GNU readline can mishandle wide characters when meta conversion is on.
|
||||
These binds are no-ops or ignored on libedit. Full grapheme editing still
|
||||
depends on the terminal and readline version (see CPython #142162).
|
||||
"""
|
||||
parse = getattr(readline_mod, "parse_and_bind", None)
|
||||
if not callable(parse):
|
||||
return
|
||||
for cmd in (
|
||||
"set input-meta on",
|
||||
"set output-meta on",
|
||||
"set convert-meta off",
|
||||
"set enable-meta-key on",
|
||||
"set horizontal-scroll-mode off",
|
||||
):
|
||||
with contextlib.suppress(Exception):
|
||||
_ = parse(cmd)
|
||||
|
||||
|
||||
def setup_readline(state: ReplState) -> None:
|
||||
"""Configure Tab completion and persistent history when readline is available."""
|
||||
try:
|
||||
@@ -85,6 +108,7 @@ def setup_readline(state: ReplState) -> None:
|
||||
return
|
||||
|
||||
bind_tab_complete(readline)
|
||||
bind_utf8_input(readline)
|
||||
# Treat path-like chars as part of a token so /help completes as one word.
|
||||
readline.set_completer_delims(" \t\n")
|
||||
readline.set_completer(build_completer(state))
|
||||
|
||||
@@ -25,12 +25,14 @@ def _echo_user(text: str) -> None:
|
||||
async def run_repl(state: ReplState) -> None:
|
||||
"""Interactive chat loop with readline editing, history, and Tab completion."""
|
||||
setup_readline(state)
|
||||
yolo = state.effective_yolo()
|
||||
yolo_part = f" yolo={yolo}" if yolo != "off" else ""
|
||||
click.echo(
|
||||
f"plyngent chat provider={state.provider_name} model={state.model} "
|
||||
f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} "
|
||||
f"rounds={state.max_rounds} messages={len(state.agent.messages)} "
|
||||
f"stream={'on' if state.agent.stream else 'off'} "
|
||||
f"verbose={'on' if state.verbose else 'off'}"
|
||||
f"verbose={'on' if state.verbose else 'off'}{yolo_part}"
|
||||
)
|
||||
click.echo('Type /help for commands. Multiline: """ … """. Empty line is ignored.')
|
||||
|
||||
@@ -52,8 +54,14 @@ async def run_repl(state: ReplState) -> None:
|
||||
text = state.pending_user_text
|
||||
state.pending_user_text = None
|
||||
_echo_user(text)
|
||||
_ = await run_user_text_with_retries(state.agent, text)
|
||||
try:
|
||||
_ = await run_user_text_with_retries(state.agent, text)
|
||||
finally:
|
||||
state.expire_yolo_once()
|
||||
continue
|
||||
|
||||
_echo_user(entry)
|
||||
_ = await run_user_text_with_retries(state.agent, entry)
|
||||
try:
|
||||
_ = await run_user_text_with_retries(state.agent, entry)
|
||||
finally:
|
||||
state.expire_yolo_once()
|
||||
|
||||
@@ -17,8 +17,23 @@ if TYPE_CHECKING:
|
||||
from plyngent.agent import AgentEvent
|
||||
from plyngent.agent.chat import ChatAgent
|
||||
|
||||
# Wait before retry attempt 1, 2, and 3 (after the first failure).
|
||||
DEFAULT_RETRY_DELAYS_SECONDS: tuple[float, ...] = (10.0, 20.0, 30.0)
|
||||
# Auto-retry budget after the first failure (10 attempts by default).
|
||||
DEFAULT_MAX_AUTO_RETRIES = 10
|
||||
# First four waits; each further wait is previous + 10s.
|
||||
_RETRY_BASE_DELAYS_SECONDS: tuple[float, ...] = (5.0, 10.0, 15.0, 20.0)
|
||||
|
||||
|
||||
def default_retry_delays(max_retries: int = DEFAULT_MAX_AUTO_RETRIES) -> tuple[float, ...]:
|
||||
"""Build wait times: 5, 10, 15, 20, then +10s each step, length *max_retries*."""
|
||||
if max_retries <= 0:
|
||||
return ()
|
||||
delays: list[float] = list(_RETRY_BASE_DELAYS_SECONDS)
|
||||
while len(delays) < max_retries:
|
||||
delays.append(delays[-1] + 10.0)
|
||||
return tuple(delays[:max_retries])
|
||||
|
||||
|
||||
DEFAULT_RETRY_DELAYS_SECONDS: tuple[float, ...] = default_retry_delays()
|
||||
_PREVIEW_LEN = 80
|
||||
|
||||
|
||||
@@ -51,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()
|
||||
|
||||
|
||||
+484
-66
@@ -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
|
||||
@@ -23,26 +24,70 @@ from plyngent.runtime import ProviderNotSupportedError
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Sequence
|
||||
|
||||
from plyngent.cli.state import ReplState
|
||||
from plyngent.cli.state import ReplState, YoloMode
|
||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||
|
||||
_DEFAULT_HISTORY_LINES = 20
|
||||
_CONTENT_PREVIEW = 200
|
||||
_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, 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"
|
||||
)
|
||||
|
||||
|
||||
@@ -86,6 +131,30 @@ class OnOffParam(click.ParamType[bool]):
|
||||
ON_OFF = OnOffParam()
|
||||
|
||||
|
||||
class YoloModeParam(click.ParamType[str]):
|
||||
"""Accept on|off|once for soft destructive-tool confirms."""
|
||||
|
||||
name: str = "yolo_mode"
|
||||
|
||||
@override
|
||||
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> str:
|
||||
if isinstance(value, str) and value in _YOLO_MODE_CHOICES:
|
||||
return value
|
||||
token = str(value).strip().lower()
|
||||
if token in _YOLO_MODE_CHOICES:
|
||||
return token
|
||||
msg = "expected on, off, or once"
|
||||
raise click.BadParameter(msg, ctx=ctx, param=param)
|
||||
|
||||
@override
|
||||
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
||||
del ctx, param
|
||||
return _filter_choices(incomplete, _YOLO_MODE_CHOICES)
|
||||
|
||||
|
||||
YOLO_MODE = YoloModeParam()
|
||||
|
||||
|
||||
class ExportFormatParam(click.ParamType[str]):
|
||||
"""First token of /export: md|json (or a path if not a format)."""
|
||||
|
||||
@@ -105,6 +174,80 @@ class ExportFormatParam(click.ParamType[str]):
|
||||
EXPORT_FORMAT = ExportFormatParam()
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -207,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]
|
||||
@@ -276,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
|
||||
|
||||
@@ -298,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:
|
||||
@@ -355,7 +539,8 @@ def status_cmd(state: ReplState) -> None:
|
||||
f"tools={'on' if state.tools_enabled else 'off'} "
|
||||
f"rounds={state.max_rounds} "
|
||||
f"stream={'on' if state.agent.stream else 'off'} "
|
||||
f"verbose={'on' if state.verbose else 'off'}\n"
|
||||
f"verbose={'on' if state.verbose else 'off'} "
|
||||
f"yolo={state.effective_yolo()}\n"
|
||||
f"context_tokens={ctx_tilde}{ctx_tokens}/{ctx_budget} ({ctx_tag}) "
|
||||
f"context_chars={ctx_chars} "
|
||||
f"tool_result_max={state.agent.max_tool_result_chars}\n"
|
||||
@@ -372,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
|
||||
@@ -409,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)."""
|
||||
@@ -512,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."""
|
||||
@@ -550,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")
|
||||
|
||||
|
||||
@@ -581,7 +772,8 @@ def provider_cmd(state: ReplState, name: str | None) -> None:
|
||||
state.provider_name = pname
|
||||
state.provider = provider
|
||||
state.rebuild_client()
|
||||
choices = _await(state.merged_model_choices(refresh=False))
|
||||
# Always request remote catalog for the new provider (bypass stale cache).
|
||||
choices = _await(state.merged_model_choices(refresh=True))
|
||||
if prev_model and (prev_model in choices or prev_model in provider.models):
|
||||
state.model = prev_model
|
||||
else:
|
||||
@@ -610,13 +802,19 @@ 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:
|
||||
"""List models (config plus remote GET /models)."""
|
||||
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
|
||||
remote_err: str | None = None
|
||||
try:
|
||||
remote = _await(state.ensure_remote_models(refresh=refresh))
|
||||
remote = _await(state.ensure_remote_models(refresh=True))
|
||||
except (RuntimeError, TypeError, OSError, ValueError) as exc:
|
||||
remote_err = str(exc)
|
||||
remote = state.cached_remote_models()
|
||||
@@ -634,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)
|
||||
|
||||
@@ -643,10 +849,10 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
|
||||
remote_set = set(remote or ())
|
||||
for mid in choices:
|
||||
tags: list[str] = []
|
||||
if mid in config_ids:
|
||||
tags.append("config")
|
||||
if mid in remote_set:
|
||||
tags.append("remote")
|
||||
if mid in config_ids:
|
||||
tags.append("config")
|
||||
suffix = f" ({', '.join(tags)})" if tags else ""
|
||||
mark = " *" if mid == state.model else ""
|
||||
click.echo(f"{mid}{mark}{suffix}")
|
||||
@@ -655,31 +861,51 @@ def models_cmd(state: ReplState, *, refresh: bool) -> None:
|
||||
click.secho(f"remote list unavailable: {remote_err}", fg="yellow", err=True)
|
||||
elif remote is not None:
|
||||
click.echo(
|
||||
f"({len(remote)} remote, {len(config_ids)} config; cache TTL {int(DEFAULT_MODELS_CACHE_TTL)}s)",
|
||||
f"(remote-first: {len(remote)} remote, {len(config_ids)} config; "
|
||||
f"cache TTL {int(DEFAULT_MODELS_CACHE_TTL)}s)",
|
||||
err=True,
|
||||
)
|
||||
|
||||
|
||||
@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: config plus cached remote)."""
|
||||
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=False))
|
||||
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")
|
||||
@@ -698,6 +924,24 @@ def tools_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT001
|
||||
click.echo(f"tools={'on' if enabled else 'off'}")
|
||||
|
||||
|
||||
@slash.command("yolo")
|
||||
@click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]")
|
||||
@click.pass_obj
|
||||
def yolo_cmd(state: ReplState, mode: str | None) -> None:
|
||||
"""Show or set YOLO mode for soft destructive-tool confirms.
|
||||
|
||||
``off`` (default when config ``confirm_destructive`` is true): prompt on
|
||||
delete/move/overwrite (deny in non-TTY). ``on``: skip confirms for the
|
||||
process. ``once``: skip for the next user turn only, then return to ``off``.
|
||||
Path/command denylists still apply. Omit the argument to print the value.
|
||||
"""
|
||||
if mode is None:
|
||||
click.echo(f"yolo={state.effective_yolo()}")
|
||||
return
|
||||
state.set_yolo(cast("YoloMode", mode))
|
||||
click.echo(f"yolo={state.effective_yolo()}")
|
||||
|
||||
|
||||
@slash.command("stream")
|
||||
@click.argument("enabled", required=False, type=ON_OFF, metavar="[on|off]")
|
||||
@click.pass_obj
|
||||
@@ -753,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")
|
||||
@@ -811,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:
|
||||
@@ -829,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:
|
||||
|
||||
+182
-14
@@ -4,10 +4,11 @@ import contextlib
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from typing import TYPE_CHECKING, Literal, cast
|
||||
|
||||
from plyngent.agent import ChatAgent, ChatClient, ToolRegistry
|
||||
from plyngent.agent.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,14 +18,18 @@ 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
|
||||
from plyngent.memory.database.schema import Session as SessionRow
|
||||
|
||||
type YoloMode = Literal["off", "on", "once"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReplState:
|
||||
@@ -44,13 +49,18 @@ class ReplState:
|
||||
markdown_enabled: bool = True
|
||||
# One-shot / scripts: never prompt to raise tool-loop limits.
|
||||
interactive_limits: bool = True
|
||||
# When False, skip destructive-tool confirms (e.g. --yes).
|
||||
confirm_destructive: bool | None = None
|
||||
# Soft destructive-tool confirms: None → derive from config.confirm_destructive.
|
||||
# off = confirm; on = skip (sticky); once = skip next user turn then off.
|
||||
yolo: YoloMode | None = None
|
||||
# Set by /edit; REPL sends as the next user turn then clears.
|
||||
pending_user_text: str | None = None
|
||||
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)
|
||||
@@ -63,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
|
||||
@@ -77,10 +88,70 @@ class ReplState:
|
||||
raise RuntimeError(msg)
|
||||
return key
|
||||
|
||||
def _confirm_destructive(self) -> bool:
|
||||
if self.confirm_destructive is not None:
|
||||
return self.confirm_destructive
|
||||
return self.config.agent_config.confirm_destructive
|
||||
def effective_yolo(self) -> YoloMode:
|
||||
"""Resolved YOLO mode (session override or config default)."""
|
||||
if self.yolo is not None:
|
||||
return self.yolo
|
||||
return "off" if self.config.agent_config.confirm_destructive else "on"
|
||||
|
||||
def soft_confirm_enabled(self) -> bool:
|
||||
"""Whether destructive tools should prompt (or deny non-interactively)."""
|
||||
return self.effective_yolo() == "off"
|
||||
|
||||
def set_yolo(self, mode: YoloMode) -> None:
|
||||
"""Set YOLO mode; rebuild tool registry when soft-confirm hooks change."""
|
||||
prev = self.soft_confirm_enabled()
|
||||
self.yolo = mode
|
||||
if prev != self.soft_confirm_enabled():
|
||||
self.rebuild_client()
|
||||
|
||||
def expire_yolo_once(self, *, quiet: bool = False) -> None:
|
||||
"""If mode is ``once``, drop back to ``off`` after a user turn."""
|
||||
if self.effective_yolo() != "once":
|
||||
return
|
||||
self.set_yolo("off")
|
||||
if not quiet:
|
||||
import click
|
||||
|
||||
click.secho("yolo=off (once expired)", fg="bright_black", err=True)
|
||||
|
||||
def _bind_todo_tools(self) -> None:
|
||||
"""Point module-level todo tools at this session stack + persist hook."""
|
||||
import asyncio
|
||||
|
||||
def on_change() -> None:
|
||||
if self.session_id is None:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
task = loop.create_task(self.memory.update_session_todo_stack(self.session_id, self.todo_stack.to_raw()))
|
||||
# Keep a strong ref until done so the task is not GC'd mid-flight.
|
||||
self._todo_persist_tasks.add(task)
|
||||
task.add_done_callback(self._todo_persist_tasks.discard)
|
||||
|
||||
set_todo_stack(self.todo_stack, on_change=on_change)
|
||||
|
||||
async def persist_todo_stack(self) -> None:
|
||||
"""Write the in-memory todo stack to the active session row."""
|
||||
if self.session_id is None:
|
||||
return
|
||||
_ = await self.memory.update_session_todo_stack(self.session_id, self.todo_stack.to_raw())
|
||||
|
||||
async def load_todo_stack(self) -> None:
|
||||
"""Load todo stack from the active session (empty if none)."""
|
||||
if self.session_id is None:
|
||||
self.todo_stack = TodoStack()
|
||||
self._bind_todo_tools()
|
||||
if hasattr(self, "agent"):
|
||||
self.agent.todo_stack = self.todo_stack
|
||||
return
|
||||
raw = await self.memory.get_session_todo_stack(self.session_id)
|
||||
self.todo_stack = TodoStack.from_raw(raw)
|
||||
self._bind_todo_tools()
|
||||
if hasattr(self, "agent"):
|
||||
self.agent.todo_stack = self.todo_stack
|
||||
|
||||
def _tool_registry(self) -> ToolRegistry | None:
|
||||
if not self.tools_enabled:
|
||||
@@ -88,7 +159,7 @@ class ReplState:
|
||||
from plyngent.cli.limits import prompt_confirm_tool_async
|
||||
from plyngent.tools.danger import classify_danger
|
||||
|
||||
if self._confirm_destructive():
|
||||
if self.soft_confirm_enabled():
|
||||
return ToolRegistry(
|
||||
list(DEFAULT_TOOLS),
|
||||
danger=classify_danger,
|
||||
@@ -115,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()
|
||||
@@ -142,6 +218,52 @@ class ReplState:
|
||||
self._remote_models_key = None
|
||||
self._remote_models_error = None
|
||||
|
||||
def seed_remote_models(self, ids: list[str]) -> None:
|
||||
"""Install a freshly fetched remote catalog into the session cache."""
|
||||
self._remote_models = list(ids)
|
||||
self._remote_models_fetched_at = time.monotonic()
|
||||
self._remote_models_key = self._models_cache_key()
|
||||
self._remote_models_error = None
|
||||
|
||||
def 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:
|
||||
@@ -174,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)
|
||||
@@ -184,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)
|
||||
|
||||
@@ -258,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
|
||||
|
||||
@@ -321,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:
|
||||
@@ -372,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."""
|
||||
@@ -391,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"
|
||||
|
||||
@@ -398,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
|
||||
|
||||
@@ -429,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
|
||||
@@ -442,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
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,6 +32,8 @@ class Session(PlyngentBase):
|
||||
# Last selected provider/model for this session (config provider key + model id).
|
||||
provider_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
model: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
# Todo/task stack JSON for multi-step sub-tasks (optional).
|
||||
todo_stack: Mapped[dict[str, object] | None] = mapped_column(JSON(), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Self
|
||||
from typing import TYPE_CHECKING, Self, cast
|
||||
|
||||
import msgspec
|
||||
from sqlalchemy import delete, select, text
|
||||
@@ -68,6 +68,7 @@ class MemoryStore:
|
||||
_ = await conn.run_sync(PlyngentBase.metadata.create_all)
|
||||
await conn.run_sync(_migrate_session_workspace)
|
||||
await conn.run_sync(_migrate_session_llm)
|
||||
await conn.run_sync(_migrate_session_todo_stack)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Dispose the underlying engine."""
|
||||
@@ -208,6 +209,31 @@ class MemoryStore:
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
async def get_session_todo_stack(self, sid: int) -> dict[str, object] | None:
|
||||
"""Return the stored todo stack JSON for *sid*, or None."""
|
||||
async with self._session_factory() as session:
|
||||
row = await session.get(Session, sid)
|
||||
if row is None:
|
||||
msg = f"session not found: {sid}"
|
||||
raise ValueError(msg)
|
||||
raw = row.todo_stack
|
||||
if raw is None:
|
||||
return None
|
||||
return {str(k): v for k, v in cast("dict[object, object]", raw).items()}
|
||||
|
||||
async def update_session_todo_stack(self, sid: int, data: dict[str, object] | None) -> Session:
|
||||
"""Persist todo stack JSON for a session (None clears)."""
|
||||
async with self._session_factory() as session:
|
||||
row = await session.get(Session, sid)
|
||||
if row is None:
|
||||
msg = f"session not found: {sid}"
|
||||
raise ValueError(msg)
|
||||
row.todo_stack = data
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
async def rename_session(self, sid: int, name: str) -> Session:
|
||||
"""Rename a session (max 64 characters, non-empty after strip)."""
|
||||
cleaned = name.strip()
|
||||
@@ -308,3 +334,14 @@ def _migrate_session_llm(sync_conn: object) -> None:
|
||||
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN provider_name VARCHAR(128)"))
|
||||
if "model" not in columns:
|
||||
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN model VARCHAR(256)"))
|
||||
|
||||
|
||||
def _migrate_session_todo_stack(sync_conn: object) -> None:
|
||||
"""Add ``session.todo_stack`` JSON for the todo/task sub-task stack."""
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
if not isinstance(sync_conn, Connection):
|
||||
return
|
||||
columns = _session_columns(sync_conn)
|
||||
if "todo_stack" not in columns:
|
||||
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN todo_stack JSON"))
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -4,9 +4,9 @@ from plyngent.agent import tool
|
||||
from plyngent.prompting import NonInteractiveError, ask_async
|
||||
|
||||
|
||||
@tool
|
||||
@tool(name="ask_user_line")
|
||||
async def ask_user(question: str, default: str = "") -> str:
|
||||
"""Ask the human a free-form question and return their answer.
|
||||
"""Ask the human a free-form one-line question and return their answer.
|
||||
|
||||
Always allows arbitrary text. Use for clarifying requirements, preferences,
|
||||
or any input that is not a fixed menu. Optional ``default`` is used if the
|
||||
|
||||
@@ -46,7 +46,7 @@ def parse_options(raw: str) -> list[ChoiceOption]:
|
||||
return out
|
||||
|
||||
|
||||
@tool
|
||||
@tool(name="ask_user_choice")
|
||||
async def choose_user(
|
||||
question: str,
|
||||
options: str,
|
||||
@@ -54,7 +54,7 @@ async def choose_user(
|
||||
*,
|
||||
allow_custom: bool = True,
|
||||
) -> str:
|
||||
"""Ask the human to pick from options (or type a custom answer).
|
||||
"""Ask the human to pick from a list of options (or type a custom answer).
|
||||
|
||||
``options`` is a JSON array of strings, or objects with
|
||||
``label``, optional ``description``, optional ``value``.
|
||||
|
||||
@@ -54,13 +54,13 @@ def parse_fields(raw: str) -> list[FormField]:
|
||||
return out
|
||||
|
||||
|
||||
@tool
|
||||
@tool(name="ask_user_form")
|
||||
async def form_user(title: str, fields: str, *, confirm_submit: bool = True) -> str:
|
||||
"""Run a multi-step form with the human; returns JSON object of answers.
|
||||
|
||||
``fields`` is a JSON array of objects:
|
||||
``name``, ``prompt``, optional ``default``, optional ``options`` (same shape
|
||||
as choose_user), optional ``allow_custom`` (default true).
|
||||
as ask_user_choice), optional ``allow_custom`` (default true).
|
||||
When ``confirm_submit`` is true, the human reviews a summary before submit.
|
||||
"""
|
||||
try:
|
||||
|
||||
+234
-23
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,12 +3,33 @@ from __future__ import annotations
|
||||
from plyngent.agent import tool
|
||||
from plyngent.tools.workspace import resolve_path
|
||||
|
||||
_LINENO_WIDTH = 6
|
||||
|
||||
|
||||
def _format_with_lineno(lines: list[str], *, start_lineno: int) -> str:
|
||||
"""Prefix each line with a 1-based absolute line number (``edit_lineno`` style)."""
|
||||
out: list[str] = []
|
||||
for index, line in enumerate(lines):
|
||||
lineno = start_lineno + index
|
||||
# Strip keepends for the body; re-add a single newline after the prefix.
|
||||
body = line.rstrip("\r\n")
|
||||
out.append(f"{lineno:>{_LINENO_WIDTH}}|{body}\n")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
@tool
|
||||
def read_file(path: str, *, offset: int = 0, limit: int | None = None) -> str:
|
||||
def read_file(
|
||||
path: str,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int | None = None,
|
||||
with_lineno: bool = False,
|
||||
) -> str:
|
||||
"""Read a text file under the workspace.
|
||||
|
||||
``offset`` is 0-based line start; ``limit`` is max lines (None = rest of file).
|
||||
When ``with_lineno`` is true, each line is prefixed with its 1-based file line
|
||||
number (`` N|…``), matching ``edit_lineno`` numbering.
|
||||
"""
|
||||
target = resolve_path(path)
|
||||
if not target.is_file():
|
||||
@@ -21,4 +42,7 @@ def read_file(path: str, *, offset: int = 0, limit: int | None = None) -> str:
|
||||
end = len(lines) if limit is None else min(len(lines), start + limit)
|
||||
if start >= len(lines):
|
||||
return ""
|
||||
return "".join(lines[start:end])
|
||||
slice_lines = lines[start:end]
|
||||
if with_lineno:
|
||||
return _format_with_lineno(slice_lines, start_lineno=start + 1)
|
||||
return "".join(slice_lines)
|
||||
|
||||
@@ -4,9 +4,10 @@ from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import tool
|
||||
from plyngent.tools.workspace import WorkspaceError, resolve_path
|
||||
from plyngent.tools.workspace import WorkspaceError, get_path_denylist, resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_MAX_DEPTH = 4
|
||||
@@ -26,21 +27,58 @@ VCS_DIR_NAMES: frozenset[str] = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# Extra noise dirs skipped by default (in addition to VCS / optional hidden).
|
||||
# Pass skip_dirs=[] to disable this list (VCS still always skipped).
|
||||
DEFAULT_NOISE_DIR_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
"dist",
|
||||
"build",
|
||||
"target",
|
||||
".tox",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
".pytest_cache",
|
||||
"coverage",
|
||||
".next",
|
||||
".nuxt",
|
||||
".turbo",
|
||||
".cache",
|
||||
"eggs",
|
||||
".eggs",
|
||||
"htmlcov",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _TreeLimits:
|
||||
max_depth: int
|
||||
max_entries: int
|
||||
skip_hidden_dirs: bool
|
||||
skip_basenames: frozenset[str]
|
||||
apply_path_denylist: bool
|
||||
|
||||
|
||||
def _skip_directory(name: str, *, skip_hidden_dirs: bool) -> bool:
|
||||
if name in VCS_DIR_NAMES:
|
||||
def _skip_directory(name: str, *, limits: _TreeLimits) -> bool:
|
||||
if name in VCS_DIR_NAMES or name in limits.skip_basenames:
|
||||
return True
|
||||
return bool(skip_hidden_dirs and name.startswith("."))
|
||||
return bool(limits.skip_hidden_dirs and name.startswith("."))
|
||||
|
||||
|
||||
def _list_children(directory: Path, *, skip_hidden_dirs: bool) -> list[Path] | str:
|
||||
def _path_denied(path: Path) -> bool:
|
||||
"""True when resolved path matches a path_denylist substring."""
|
||||
denylist = get_path_denylist()
|
||||
if not denylist:
|
||||
return False
|
||||
resolved_str = str(path).replace("\\", "/")
|
||||
return any(pattern and pattern.replace("\\", "/") in resolved_str for pattern in denylist)
|
||||
|
||||
|
||||
def _list_children(directory: Path, *, limits: _TreeLimits) -> list[Path] | str:
|
||||
try:
|
||||
children = list(directory.iterdir())
|
||||
except OSError as exc:
|
||||
@@ -51,7 +89,9 @@ def _list_children(directory: Path, *, skip_hidden_dirs: bool) -> list[Path] | s
|
||||
is_dir = child.is_dir()
|
||||
except OSError:
|
||||
continue
|
||||
if is_dir and _skip_directory(child.name, skip_hidden_dirs=skip_hidden_dirs):
|
||||
if is_dir and _skip_directory(child.name, limits=limits):
|
||||
continue
|
||||
if limits.apply_path_denylist and _path_denied(child):
|
||||
continue
|
||||
visible.append(child)
|
||||
# Directories first, then files; alphabetical within each group.
|
||||
@@ -70,7 +110,7 @@ def _render_tree(
|
||||
if depth >= limits.max_depth:
|
||||
return
|
||||
|
||||
children = _list_children(directory, skip_hidden_dirs=limits.skip_hidden_dirs)
|
||||
children = _list_children(directory, limits=limits)
|
||||
if isinstance(children, str):
|
||||
lines.append(f"{prefix}{children}")
|
||||
return
|
||||
@@ -104,6 +144,13 @@ def _render_tree(
|
||||
lines.append(f"{prefix}└── … ({more} more entries not shown)")
|
||||
|
||||
|
||||
def _resolve_skip_basenames(skip_dirs: Sequence[str] | None) -> frozenset[str]:
|
||||
"""None → default noise set; explicit list (including empty) replaces defaults."""
|
||||
if skip_dirs is None:
|
||||
return DEFAULT_NOISE_DIR_NAMES
|
||||
return frozenset(name for name in skip_dirs if name)
|
||||
|
||||
|
||||
@tool
|
||||
def tree(
|
||||
path: str = ".",
|
||||
@@ -111,13 +158,22 @@ def tree(
|
||||
max_depth: int = DEFAULT_MAX_DEPTH,
|
||||
max_entries: int = DEFAULT_MAX_ENTRIES,
|
||||
skip_hidden_dirs: bool = True,
|
||||
skip_dirs: list[str] | None = None,
|
||||
apply_path_denylist: bool = True,
|
||||
) -> str:
|
||||
"""Show a directory tree under the workspace.
|
||||
|
||||
Always skips VCS metadata directories (``.git``, ``.hg``, ``.svn``, …).
|
||||
By default also skips common noise dirs (``node_modules``, ``__pycache__``,
|
||||
``.venv``, ``dist``, …). Pass ``skip_dirs=[]`` to disable the noise list
|
||||
(VCS still skipped). Pass an explicit list to replace the default noise set.
|
||||
|
||||
By default skips other dot-directories (not hidden files). Use
|
||||
``skip_hidden_dirs=false`` to include them.
|
||||
|
||||
``apply_path_denylist`` (default true) hides entries whose full path matches
|
||||
the agent ``path_denylist`` policy.
|
||||
|
||||
``max_depth`` limits how deep directories are expanded (1 = origin + children).
|
||||
``max_entries`` caps how many entries are listed per directory.
|
||||
"""
|
||||
@@ -135,15 +191,18 @@ def tree(
|
||||
|
||||
root_label = path.rstrip("/\\") or "."
|
||||
lines = [f"{root_label}/"]
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.agent import tool
|
||||
from plyngent.tools.workspace import (
|
||||
MAX_TEMPORARY_WORKSPACES,
|
||||
WorkspaceError,
|
||||
add_workspace_allowlist,
|
||||
list_workspace_allowlist,
|
||||
pop_owned_temporary_workspaces,
|
||||
remove_workspace_allowlist,
|
||||
)
|
||||
|
||||
_DEFAULT_PREFIX = "ws"
|
||||
_PREFIX_MAX_LEN = 32
|
||||
_PREFIX_SAFE = re.compile(rf"^[A-Za-z0-9_-]{{1,{_PREFIX_MAX_LEN}}}$")
|
||||
|
||||
|
||||
def _sanitize_prefix(prefix: str) -> str:
|
||||
token = (prefix or _DEFAULT_PREFIX).strip() or _DEFAULT_PREFIX
|
||||
# Replace unsafe characters so mkdtemp stays portable on Windows/POSIX.
|
||||
cleaned = re.sub(r"[^A-Za-z0-9_-]+", "-", token)
|
||||
cleaned = cleaned.strip("-_") or _DEFAULT_PREFIX
|
||||
if len(cleaned) > _PREFIX_MAX_LEN:
|
||||
cleaned = cleaned[:_PREFIX_MAX_LEN]
|
||||
if not _PREFIX_SAFE.match(cleaned):
|
||||
return _DEFAULT_PREFIX
|
||||
return cleaned
|
||||
|
||||
|
||||
def _is_under_system_temp(path: Path) -> bool:
|
||||
"""True if *path* is under the OS temporary directory (safe to rmtree)."""
|
||||
try:
|
||||
temp_root = Path(tempfile.gettempdir()).expanduser().resolve()
|
||||
resolved = path.expanduser().resolve()
|
||||
_ = resolved.relative_to(temp_root)
|
||||
except OSError, ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def cleanup_temporary_workspaces() -> int:
|
||||
"""Remove directories created by :func:`new_temporary_workspace` (chat exit).
|
||||
|
||||
Only deletes paths still under the system temp dir. Returns the number of
|
||||
directories removed.
|
||||
"""
|
||||
removed = 0
|
||||
for path in pop_owned_temporary_workspaces():
|
||||
if not _is_under_system_temp(path):
|
||||
remove_workspace_allowlist(path)
|
||||
continue
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
removed += 1
|
||||
remove_workspace_allowlist(path)
|
||||
return removed
|
||||
|
||||
|
||||
@tool
|
||||
def new_temporary_workspace(prefix: str = "ws") -> str:
|
||||
"""Create a scratch directory under the system temp dir and allow tool paths in it.
|
||||
|
||||
The project workspace is unchanged: relative paths still resolve there.
|
||||
Use the returned **absolute** path for file/process tools. Temporary
|
||||
workspaces are deleted when this chat process exits (not on each turn).
|
||||
|
||||
Cross-platform (uses ``tempfile``; typically ``/tmp`` on POSIX, ``%TEMP%``
|
||||
on Windows). At most 16 concurrent temporary workspaces per process.
|
||||
"""
|
||||
if len(list_workspace_allowlist()) >= MAX_TEMPORARY_WORKSPACES:
|
||||
return f"error: too many temporary workspaces (max {MAX_TEMPORARY_WORKSPACES})"
|
||||
|
||||
safe = _sanitize_prefix(prefix)
|
||||
try:
|
||||
# mkdtemp is cross-platform; prefix must end with - for readability.
|
||||
path_str = tempfile.mkdtemp(prefix=f"plyngent-{safe}-")
|
||||
path = Path(path_str).resolve()
|
||||
except OSError as exc:
|
||||
return f"error: failed to create temporary workspace: {exc}"
|
||||
|
||||
try:
|
||||
_ = add_workspace_allowlist(path, owned=True)
|
||||
except WorkspaceError as exc:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
return f"error: {exc}"
|
||||
|
||||
return (
|
||||
f"temporary_workspace={path}\n"
|
||||
"note: project workspace unchanged; use this absolute path for tools; "
|
||||
"removed when chat exits"
|
||||
)
|
||||
@@ -0,0 +1,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]
|
||||
@@ -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)
|
||||
|
||||
@@ -56,4 +56,14 @@ async def test_confirm_deny() -> None:
|
||||
|
||||
async def test_no_hooks_skips_confirm() -> None:
|
||||
registry = ToolRegistry([delete_path])
|
||||
assert registry.soft_confirm is False
|
||||
assert await registry.execute("delete_path", '{"path": "a.txt"}') == "deleted a.txt"
|
||||
|
||||
|
||||
async def test_soft_confirm_property() -> None:
|
||||
def on_confirm(name: str, args: Mapping[str, object], reason: str) -> bool:
|
||||
del name, args, reason
|
||||
return True
|
||||
|
||||
gated = ToolRegistry([delete_path], danger=classify_danger, on_confirm=on_confirm)
|
||||
assert gated.soft_confirm is True
|
||||
|
||||
@@ -30,6 +30,7 @@ from plyngent.lmproto.openai_compatible.model import (
|
||||
ChatCompletionsParam,
|
||||
ChunkChoice,
|
||||
DeltaMessage,
|
||||
FinishReason,
|
||||
StreamFunctionDelta,
|
||||
StreamToolCallDelta,
|
||||
ToolChatMessage,
|
||||
@@ -61,6 +62,28 @@ def _chunks_from_response(response: ChatCompletionResponse) -> list[ChatCompleti
|
||||
],
|
||||
)
|
||||
)
|
||||
# Emit terminal finish_reason when content was streamed without one.
|
||||
fr = response.choices[0].finish_reason
|
||||
if (
|
||||
fr
|
||||
and (isinstance(message.content, str) and message.content)
|
||||
and (message.tool_calls is UNSET or not message.tool_calls)
|
||||
):
|
||||
chunks.append(
|
||||
ChatCompletionChunk(
|
||||
id=response.id,
|
||||
object="chat.completion.chunk",
|
||||
created=response.created,
|
||||
model=response.model,
|
||||
choices=[
|
||||
ChunkChoice(
|
||||
index=0,
|
||||
delta=DeltaMessage(),
|
||||
finish_reason=fr,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
tool_calls = message.tool_calls
|
||||
if tool_calls is not UNSET and tool_calls:
|
||||
deltas: list[StreamToolCallDelta] = []
|
||||
@@ -152,7 +175,8 @@ class ScriptedClient:
|
||||
def _response(
|
||||
message: AssistantChatMessage,
|
||||
*,
|
||||
usage: dict[str, int] | None = None,
|
||||
usage: dict[str, object] | None = None,
|
||||
finish_reason: FinishReason | None = "stop",
|
||||
) -> ChatCompletionResponse:
|
||||
return ChatCompletionResponse(
|
||||
id="1",
|
||||
@@ -164,7 +188,7 @@ def _response(
|
||||
index=0,
|
||||
message=message,
|
||||
logprobs={},
|
||||
finish_reason="stop",
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
],
|
||||
system_fingerprint="",
|
||||
@@ -825,3 +849,118 @@ async def test_tool_result_char_budget() -> None:
|
||||
assert len(tool_msgs) == 1
|
||||
assert tool_msgs[0].content.startswith("x" * 20)
|
||||
assert "truncated" in tool_msgs[0].content
|
||||
|
||||
|
||||
async def test_empty_completion_raises() -> None:
|
||||
client = ScriptedClient([_response(AssistantChatMessage(content=None))])
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
with pytest.raises(RuntimeError, match="empty model completion"):
|
||||
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||
|
||||
|
||||
async def test_length_finish_raises() -> None:
|
||||
client = ScriptedClient(
|
||||
[
|
||||
_response(
|
||||
AssistantChatMessage(content="partial"),
|
||||
finish_reason="length",
|
||||
)
|
||||
]
|
||||
)
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
with pytest.raises(RuntimeError, match="truncated"):
|
||||
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||
|
||||
|
||||
async def test_content_filter_finish_raises() -> None:
|
||||
client = ScriptedClient(
|
||||
[
|
||||
_response(
|
||||
AssistantChatMessage(content=""),
|
||||
finish_reason="content_filter",
|
||||
)
|
||||
]
|
||||
)
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
with pytest.raises(RuntimeError, match="content filter"):
|
||||
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||
|
||||
|
||||
async def test_stream_missing_terminal_empty_raises() -> None:
|
||||
class EmptyStreamClient:
|
||||
@overload
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
|
||||
) -> ChatCompletionResponse: ...
|
||||
|
||||
@overload
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: Literal[True]
|
||||
) -> AsyncIterator[ChatCompletionChunk]: ...
|
||||
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||
del param
|
||||
if not stream:
|
||||
return _response(AssistantChatMessage(content="x"))
|
||||
|
||||
async def chunks() -> AsyncIterator[ChatCompletionChunk]:
|
||||
# Usage-only style chunk with no finish_reason and no content.
|
||||
yield ChatCompletionChunk(
|
||||
id="1",
|
||||
object="chat.completion.chunk",
|
||||
created=0,
|
||||
model="t",
|
||||
choices=[],
|
||||
usage={"prompt_tokens": 1, "completion_tokens": 0, "total_tokens": 1},
|
||||
)
|
||||
|
||||
return chunks()
|
||||
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
with pytest.raises(RuntimeError, match="stream ended without a terminal"):
|
||||
_ = [e async for e in run_chat_loop(EmptyStreamClient(), messages, model="m", stream=True)]
|
||||
|
||||
|
||||
async def test_stream_payload_without_finish_reason_ok() -> None:
|
||||
"""Some providers omit finish_reason; non-empty text still counts as terminal."""
|
||||
|
||||
class PayloadNoFinishClient:
|
||||
@overload
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
|
||||
) -> ChatCompletionResponse: ...
|
||||
|
||||
@overload
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: Literal[True]
|
||||
) -> AsyncIterator[ChatCompletionChunk]: ...
|
||||
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||
del param
|
||||
if not stream:
|
||||
return _response(AssistantChatMessage(content="ok"))
|
||||
|
||||
async def chunks() -> AsyncIterator[ChatCompletionChunk]:
|
||||
yield ChatCompletionChunk(
|
||||
id="1",
|
||||
object="chat.completion.chunk",
|
||||
created=0,
|
||||
model="t",
|
||||
choices=[
|
||||
ChunkChoice(
|
||||
index=0,
|
||||
delta=DeltaMessage(content="ok"),
|
||||
finish_reason=None,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
return chunks()
|
||||
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
events = [e async for e in run_chat_loop(PayloadNoFinishClient(), messages, model="m", stream=True)]
|
||||
assert any(isinstance(e, TextDeltaEvent) and e.content == "ok" for e in events)
|
||||
|
||||
@@ -8,6 +8,7 @@ from plyngent.agent.responses_bridge import (
|
||||
chat_param_to_responses_kwargs,
|
||||
response_to_assistant_message,
|
||||
response_to_chat_completion,
|
||||
responses_status_to_finish_reason,
|
||||
tool_items_to_response_tools,
|
||||
)
|
||||
from plyngent.agent.responses_client import ResponsesChatClient
|
||||
@@ -100,6 +101,31 @@ def test_response_to_assistant_with_tools() -> None:
|
||||
assert completion.choices[0].message.content == "done"
|
||||
assert isinstance(completion.usage, dict)
|
||||
assert completion.usage["input_tokens"] == 10
|
||||
assert completion.choices[0].finish_reason == "tool_calls"
|
||||
|
||||
|
||||
def test_responses_status_to_finish_reason_incomplete() -> None:
|
||||
body = {
|
||||
"id": "resp_i",
|
||||
"object": "response",
|
||||
"created_at": 1,
|
||||
"model": "gpt-test",
|
||||
"status": "incomplete",
|
||||
"incomplete_details": {"reason": "max_output_tokens"},
|
||||
"output": [
|
||||
{
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "incomplete",
|
||||
"content": [{"type": "output_text", "text": "partial", "annotations": []}],
|
||||
}
|
||||
],
|
||||
}
|
||||
resp = msgspec.convert(body, Response)
|
||||
assert responses_status_to_finish_reason(resp, has_tool_calls=False) == "length"
|
||||
completion = response_to_chat_completion(resp)
|
||||
assert completion.choices[0].finish_reason == "length"
|
||||
|
||||
|
||||
def test_chat_param_to_responses_kwargs() -> None:
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -37,13 +37,47 @@ async def test_render_reasoning_and_text(capsys: pytest.CaptureFixture[str]) ->
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "reasoning: " in out
|
||||
assert "reasoning:" in out
|
||||
assert "think" in out
|
||||
assert "assistant: " in out
|
||||
assert "assistant:" in out
|
||||
assert "hello" in out
|
||||
# Labels on their own lines (content begins after newline).
|
||||
assert "assistant:\nhello" in out or "assistant:\r\nhello" in out
|
||||
set_markdown_enabled(True)
|
||||
|
||||
|
||||
async def test_flush_markdown_on_source_change(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Assistant segment before tools is flushed so later text is a new segment."""
|
||||
monkeypatch.setattr("plyngent.cli.display.markdown_render_available", lambda: True)
|
||||
set_markdown_enabled(True)
|
||||
from plyngent.agent import ToolCallEvent
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantFunctionTool,
|
||||
AssistantFunctionToolCall,
|
||||
)
|
||||
|
||||
call = AssistantFunctionToolCall(
|
||||
id="1",
|
||||
function=AssistantFunctionTool(name="read_file", arguments="{}"),
|
||||
)
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
TextDeltaEvent(content="before **tool**"),
|
||||
ToolCallEvent(tool_call=call),
|
||||
TextDeltaEvent(content="after"),
|
||||
]
|
||||
),
|
||||
markdown=True,
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "[tool]" in out
|
||||
assert "after" in out
|
||||
|
||||
|
||||
async def test_tool_result_preview_vs_verbose(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
long = "x" * 200
|
||||
msg = ToolChatMessage(content=long, tool_call_id="1")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import signal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.cli.interrupt import (
|
||||
allow_task_cancel,
|
||||
pause_task_cancel_for_prompt,
|
||||
run_in_prompt_thread,
|
||||
set_sigint_reinstall,
|
||||
)
|
||||
from plyngent.cli.limits import prompt_continue_limit, prompt_continue_limit_async
|
||||
from plyngent.cli.retry import run_cancellable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
pass
|
||||
|
||||
|
||||
def test_pause_task_cancel_for_prompt() -> None:
|
||||
@@ -20,6 +26,16 @@ def test_pause_task_cancel_for_prompt() -> None:
|
||||
assert allow_task_cancel() is True
|
||||
|
||||
|
||||
def test_nested_pause_depth() -> None:
|
||||
assert allow_task_cancel() is True
|
||||
with pause_task_cancel_for_prompt():
|
||||
assert allow_task_cancel() is False
|
||||
with pause_task_cancel_for_prompt():
|
||||
assert allow_task_cancel() is False
|
||||
assert allow_task_cancel() is False
|
||||
assert allow_task_cancel() is True
|
||||
|
||||
|
||||
def test_prompt_continue_limit_under_pause(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def _confirm(*_a: object, **_k: object) -> bool:
|
||||
assert allow_task_cancel() is False
|
||||
@@ -36,7 +52,6 @@ async def test_run_in_prompt_thread_pauses_cancel() -> None:
|
||||
def work() -> str:
|
||||
return "ok"
|
||||
|
||||
# ContextVar may not propagate to worker threads; assert pause around the call.
|
||||
result = await run_in_prompt_thread(work)
|
||||
assert result == "ok"
|
||||
assert allow_task_cancel() is True
|
||||
@@ -48,3 +63,53 @@ async def test_prompt_continue_limit_async(monkeypatch: pytest.MonkeyPatch) -> N
|
||||
|
||||
monkeypatch.setattr("click.confirm", _confirm)
|
||||
assert await prompt_continue_limit_async("too many rounds") is True
|
||||
|
||||
|
||||
async def test_sigint_cancels_after_prompt_pause() -> None:
|
||||
"""Regression: after a mid-turn prompt, SIGINT must still cancel the turn.
|
||||
|
||||
Previously, reinstalling the asyncio SIGINT handler while
|
||||
allow_task_cancel was still False froze that value into the callback
|
||||
forever (ContextVar snapshot at add_signal_handler).
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
loop.add_signal_handler(signal.SIGINT, lambda: None)
|
||||
loop.remove_signal_handler(signal.SIGINT)
|
||||
except NotImplementedError, RuntimeError, ValueError:
|
||||
pytest.skip("asyncio signal handlers not available on this platform")
|
||||
|
||||
started = asyncio.Event()
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def hang() -> None:
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.sleep(3600)
|
||||
except asyncio.CancelledError:
|
||||
cancelled.set()
|
||||
raise
|
||||
|
||||
async def turn() -> None:
|
||||
# Simulate soft-confirm pause mid-turn, then keep streaming.
|
||||
with pause_task_cancel_for_prompt():
|
||||
assert allow_task_cancel() is False
|
||||
assert allow_task_cancel() is True
|
||||
await hang()
|
||||
|
||||
task = asyncio.create_task(run_cancellable(turn()))
|
||||
await started.wait()
|
||||
# Give run_cancellable a tick to install the handler after the pause reinstall.
|
||||
await asyncio.sleep(0)
|
||||
assert allow_task_cancel() is True
|
||||
# Deliver SIGINT the same way Ctrl+C does under asyncio.
|
||||
loop.call_soon(lambda: None) # ensure loop is processing
|
||||
# Invoke the process signal: raise SIGINT to this process.
|
||||
import os
|
||||
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert cancelled.is_set()
|
||||
# Cleanup any leftover reinstall hook from run_cancellable
|
||||
set_sigint_reinstall(None)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from plyngent.cli.limits import install_cli_limit_hooks, prompt_confirm_tool, prompt_continue_limit
|
||||
from plyngent.cli.limits import (
|
||||
format_tool_confirm_box,
|
||||
install_cli_limit_hooks,
|
||||
prompt_confirm_tool,
|
||||
prompt_continue_limit,
|
||||
)
|
||||
from plyngent.prompting import get_prompt_backend, temporary_backend
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
from tests.test_prompting import ScriptedBackend
|
||||
@@ -24,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
|
||||
|
||||
@@ -8,14 +8,19 @@ 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
|
||||
|
||||
|
||||
def test_merge_model_choices_union() -> None:
|
||||
assert merge_model_choices(["b", "a"], ["a", "c"]) == ["a", "b", "c"]
|
||||
def test_merge_model_choices_remote_first() -> None:
|
||||
# remote-first: remote sorted, then config-only
|
||||
assert merge_model_choices(["b", "a"], ["a", "c"]) == ["a", "c", "b"]
|
||||
assert merge_model_choices(["a"], None) == ["a"]
|
||||
assert merge_model_choices([], ["z"]) == ["z"]
|
||||
assert merge_model_choices(["cfg"], ["remote", "cfg"], prefer="remote") == ["cfg", "remote"]
|
||||
assert merge_model_choices(["b", "a"], ["a", "c"], prefer="union") == ["a", "b", "c"]
|
||||
assert merge_model_choices(["b", "a"], ["a", "c"], prefer="config") == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_model_choices_for_provider() -> None:
|
||||
@@ -26,6 +31,7 @@ def test_model_choices_for_provider() -> None:
|
||||
)
|
||||
assert config_model_ids(provider) == ["cfg"]
|
||||
assert model_choices_for_provider(provider, remote_ids=["remote", "cfg"]) == ["cfg", "remote"]
|
||||
assert model_choices_for_provider(provider, remote_ids=["remote"]) == ["remote", "cfg"]
|
||||
|
||||
|
||||
def test_client_supports_models() -> None:
|
||||
@@ -50,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
|
||||
|
||||
@@ -181,3 +181,22 @@ def test_complete_slash_args_from_registry(tmp_path: object) -> None:
|
||||
assert complete_slash_args(state, "/model", "a") == ["alpha"]
|
||||
assert complete_slash_args(state, "/export", "j") == ["json"]
|
||||
assert complete_slash_args(state, "/help", "st") == ["status", "stream"]
|
||||
assert complete_slash_args(state, "/yolo", "") == ["on", "off", "once"]
|
||||
assert complete_slash_args(state, "/yolo", "o") == ["on", "off", "once"]
|
||||
assert "push" in complete_slash_args(state, "/todos", "p")
|
||||
assert "pop" in complete_slash_args(state, "/todos", "p")
|
||||
assert "--persist" in complete_slash_args(state, "/model", "--p")
|
||||
assert "--full" in complete_slash_args(state, "/history", "--f")
|
||||
assert "32" in complete_slash_args(state, "/rounds", "3")
|
||||
|
||||
from plyngent.agent.todo_stack import TodoStack
|
||||
|
||||
state.todo_stack = TodoStack()
|
||||
_ = state.todo_stack.push_group(["alpha", "beta"])
|
||||
ids = complete_slash_args(state, "/todos", "t", prior_args=["done"])
|
||||
assert "t1" in ids
|
||||
assert "t2" in ids
|
||||
|
||||
state.remember_session_ids([3, 12, 40])
|
||||
assert complete_slash_args(state, "/resume", "1") == ["12"]
|
||||
assert complete_slash_args(state, "/delete", "4") == ["40"]
|
||||
|
||||
@@ -109,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,93 @@ 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
|
||||
assert await handle_slash(state, "/yolo") is True
|
||||
assert "yolo=off" in capsys.readouterr().out
|
||||
|
||||
assert await handle_slash(state, "/yolo on") is True
|
||||
assert state.effective_yolo() == "on"
|
||||
assert state.soft_confirm_enabled() is False
|
||||
assert "yolo=on" in capsys.readouterr().out
|
||||
|
||||
assert await handle_slash(state, "/yolo once") is True
|
||||
assert state.effective_yolo() == "once"
|
||||
assert state.soft_confirm_enabled() is False
|
||||
|
||||
state.expire_yolo_once(quiet=True)
|
||||
assert state.effective_yolo() == "off"
|
||||
assert state.soft_confirm_enabled() is True
|
||||
|
||||
assert await handle_slash(state, "/yolo once") is True
|
||||
state.expire_yolo_once()
|
||||
err = capsys.readouterr().err
|
||||
assert "yolo=off (once expired)" in err
|
||||
assert state.effective_yolo() == "off"
|
||||
|
||||
|
||||
async def test_yolo_rebuilds_tool_registry(tmp_path: Path) -> None:
|
||||
_ = set_workspace_root(tmp_path)
|
||||
memory = await MemoryStore.open(DatabaseConfig())
|
||||
provider = OpenAIProvider(access_key_or_token="sk-test")
|
||||
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
|
||||
config.providers = {"local": provider}
|
||||
st = ReplState(
|
||||
config=config,
|
||||
memory=memory,
|
||||
workspace=tmp_path,
|
||||
provider_name="local",
|
||||
provider=provider,
|
||||
model="gpt-test",
|
||||
tools_enabled=True,
|
||||
)
|
||||
try:
|
||||
assert st.agent.tools is not None
|
||||
assert st.agent.tools.soft_confirm is True
|
||||
st.set_yolo("on")
|
||||
assert st.agent.tools is not None
|
||||
assert st.agent.tools.soft_confirm is False
|
||||
st.set_yolo("once")
|
||||
assert st.agent.tools is not None
|
||||
assert st.agent.tools.soft_confirm is False
|
||||
st.set_yolo("off")
|
||||
assert st.agent.tools is not None
|
||||
assert st.agent.tools.soft_confirm is True
|
||||
finally:
|
||||
await memory.close()
|
||||
|
||||
|
||||
async def test_resume(state: ReplState) -> None:
|
||||
sid = state.session_id
|
||||
assert sid is not None
|
||||
@@ -357,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
|
||||
@@ -378,6 +534,7 @@ async def test_status_shows_context_tokens(state: ReplState, capsys: pytest.Capt
|
||||
assert "(est)" in out # no API usage yet
|
||||
assert "context_chars=" in out
|
||||
assert "tool_result_max=" in out
|
||||
assert "yolo=off" in out
|
||||
assert str(state.workspace) in out
|
||||
|
||||
state.agent.last_request_usage = TokenUsage(
|
||||
|
||||
@@ -5,7 +5,14 @@ from typing import TYPE_CHECKING, Literal, overload
|
||||
import pytest
|
||||
|
||||
from plyngent.agent import ChatAgent
|
||||
from plyngent.cli.retry import retry_pending_with_retries, run_turn_with_retries, sleep_cancellable
|
||||
from plyngent.cli.retry import (
|
||||
DEFAULT_MAX_AUTO_RETRIES,
|
||||
DEFAULT_RETRY_DELAYS_SECONDS,
|
||||
default_retry_delays,
|
||||
retry_pending_with_retries,
|
||||
run_turn_with_retries,
|
||||
sleep_cancellable,
|
||||
)
|
||||
from plyngent.config.models import DatabaseConfig
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantChatMessage,
|
||||
@@ -23,6 +30,17 @@ if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
|
||||
def test_default_retry_delays_schedule() -> None:
|
||||
assert DEFAULT_MAX_AUTO_RETRIES == 10
|
||||
assert default_retry_delays(0) == ()
|
||||
assert default_retry_delays(4) == (5.0, 10.0, 15.0, 20.0)
|
||||
delays = default_retry_delays(10)
|
||||
assert delays[:4] == (5.0, 10.0, 15.0, 20.0)
|
||||
assert delays[4:] == (30.0, 40.0, 50.0, 60.0, 70.0, 80.0)
|
||||
assert delays == DEFAULT_RETRY_DELAYS_SECONDS
|
||||
assert len(DEFAULT_RETRY_DELAYS_SECONDS) == 10
|
||||
|
||||
|
||||
def _response(message: AssistantChatMessage) -> ChatCompletionResponse:
|
||||
return ChatCompletionResponse(
|
||||
id="1",
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -12,7 +12,7 @@ async def test_ask_user_tool() -> None:
|
||||
backend = ScriptedBackend(["42"])
|
||||
with temporary_backend(backend):
|
||||
registry = ToolRegistry([ask_user])
|
||||
out = await registry.execute("ask_user", '{"question": "Answer?"}')
|
||||
out = await registry.execute("ask_user_line", '{"question": "Answer?"}')
|
||||
assert out == "42"
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ async def test_choose_user_tool_index() -> None:
|
||||
with temporary_backend(backend):
|
||||
registry = ToolRegistry([choose_user])
|
||||
out = await registry.execute(
|
||||
"choose_user",
|
||||
"ask_user_choice",
|
||||
json.dumps(
|
||||
{
|
||||
"question": "Pick",
|
||||
@@ -36,7 +36,7 @@ async def test_choose_user_tool_index() -> None:
|
||||
async def test_choose_user_bad_options() -> None:
|
||||
registry = ToolRegistry([choose_user])
|
||||
out = await registry.execute(
|
||||
"choose_user",
|
||||
"ask_user_choice",
|
||||
json.dumps({"question": "Pick", "options": "not-json"}),
|
||||
)
|
||||
assert out.startswith("error:")
|
||||
@@ -48,7 +48,7 @@ async def test_form_user_tool() -> None:
|
||||
with temporary_backend(backend):
|
||||
registry = ToolRegistry([form_user])
|
||||
out = await registry.execute(
|
||||
"form_user",
|
||||
"ask_user_form",
|
||||
json.dumps({"title": "Setup", "fields": fields, "confirm_submit": True}),
|
||||
)
|
||||
assert json.loads(out) == {"user": "ncbm"}
|
||||
@@ -56,11 +56,11 @@ async def test_form_user_tool() -> None:
|
||||
|
||||
async def test_chat_tools_in_default_list() -> None:
|
||||
names = {t.name for t in CHAT_TOOLS}
|
||||
assert names == {"ask_user", "choose_user", "form_user"}
|
||||
assert names == {"ask_user_line", "ask_user_choice", "ask_user_form"}
|
||||
|
||||
|
||||
async def test_ask_user_non_interactive_error() -> None:
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
registry = ToolRegistry([ask_user])
|
||||
out = await registry.execute("ask_user", '{"question": "hi"}')
|
||||
out = await registry.execute("ask_user_line", '{"question": "hi"}')
|
||||
assert out.startswith("error:")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.tools.file import read_file, write_file
|
||||
from plyngent.tools.temp_workspace import cleanup_temporary_workspaces, new_temporary_workspace
|
||||
from plyngent.tools.workspace import (
|
||||
WorkspaceError,
|
||||
clear_workspace_allowlist,
|
||||
list_workspace_allowlist,
|
||||
resolve_path,
|
||||
)
|
||||
from tests.test_tools.helpers import call_sync
|
||||
|
||||
|
||||
def _temp_path_from_tool_output(out: str) -> Path:
|
||||
line = next(part for part in out.splitlines() if part.startswith("temporary_workspace="))
|
||||
return Path(line.split("=", 1)[1].strip())
|
||||
|
||||
|
||||
def test_new_temporary_workspace_allowlist(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
out = call_sync(new_temporary_workspace, "unit")
|
||||
assert "temporary_workspace=" in out
|
||||
assert "project workspace unchanged" in out
|
||||
temp = _temp_path_from_tool_output(out)
|
||||
assert temp.is_dir()
|
||||
assert temp in list_workspace_allowlist()
|
||||
|
||||
# Absolute path under temp is allowed; project relative still works.
|
||||
target = temp / "scratch.txt"
|
||||
_ = target.write_text("hello-temp", encoding="utf-8")
|
||||
assert resolve_path(str(target)) == target.resolve()
|
||||
assert call_sync(read_file, str(target)) == "hello-temp"
|
||||
|
||||
_ = call_sync(write_file, "project.txt", "proj")
|
||||
assert call_sync(read_file, "project.txt") == "proj"
|
||||
|
||||
# Sibling under system temp that we did not allowlist still fails.
|
||||
outside = temp.parent / "not-ours-should-fail"
|
||||
with pytest.raises(WorkspaceError, match="escapes"):
|
||||
_ = resolve_path(str(outside))
|
||||
|
||||
|
||||
def test_cleanup_removes_owned_temps(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
out = call_sync(new_temporary_workspace)
|
||||
temp = _temp_path_from_tool_output(out)
|
||||
assert temp.is_dir()
|
||||
n = cleanup_temporary_workspaces()
|
||||
assert n >= 1
|
||||
assert not temp.exists()
|
||||
assert list_workspace_allowlist() == []
|
||||
|
||||
|
||||
def test_prefix_sanitized(workspace: object) -> None:
|
||||
del workspace
|
||||
out = call_sync(new_temporary_workspace, "bad/../x y!")
|
||||
assert "temporary_workspace=" in out
|
||||
assert not out.startswith("error")
|
||||
_ = cleanup_temporary_workspaces()
|
||||
clear_workspace_allowlist()
|
||||
@@ -83,3 +83,58 @@ def test_tree_invalid_limits(workspace: object) -> None:
|
||||
del workspace
|
||||
assert "max_depth" in call_sync(tree, ".", max_depth=0)
|
||||
assert "max_entries" in call_sync(tree, ".", max_entries=0)
|
||||
|
||||
|
||||
def test_tree_default_noise_dirs(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
(workspace / "src").mkdir()
|
||||
_ = (workspace / "src" / "app.py").write_text("x", encoding="utf-8")
|
||||
(workspace / "node_modules").mkdir()
|
||||
_ = (workspace / "node_modules" / "pkg.js").write_text("x", encoding="utf-8")
|
||||
(workspace / "__pycache__").mkdir()
|
||||
_ = (workspace / "__pycache__" / "x.pyc").write_text("x", encoding="utf-8")
|
||||
out = call_sync(tree, ".")
|
||||
assert "src/" in out
|
||||
assert "app.py" in out
|
||||
assert "node_modules" not in out
|
||||
assert "__pycache__" not in out
|
||||
|
||||
|
||||
def test_tree_skip_dirs_empty_shows_noise(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
(workspace / "node_modules").mkdir()
|
||||
_ = (workspace / "node_modules" / "pkg.js").write_text("x", encoding="utf-8")
|
||||
out = call_sync(tree, ".", skip_dirs=[])
|
||||
assert "node_modules/" in out
|
||||
assert "pkg.js" in out
|
||||
|
||||
|
||||
def test_tree_skip_dirs_custom(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
(workspace / "keep_me").mkdir()
|
||||
_ = (workspace / "keep_me" / "a.txt").write_text("x", encoding="utf-8")
|
||||
(workspace / "drop_me").mkdir()
|
||||
_ = (workspace / "drop_me" / "b.txt").write_text("x", encoding="utf-8")
|
||||
# Custom list replaces default noise set (node_modules not in list → would show if present).
|
||||
out = call_sync(tree, ".", skip_dirs=["drop_me"])
|
||||
assert "keep_me/" in out
|
||||
assert "drop_me" not in out
|
||||
|
||||
|
||||
def test_tree_path_denylist_walk(workspace: object) -> None:
|
||||
from plyngent.tools.workspace import set_path_denylist
|
||||
|
||||
assert isinstance(workspace, Path)
|
||||
(workspace / "ok").mkdir()
|
||||
_ = (workspace / "ok" / "a.txt").write_text("x", encoding="utf-8")
|
||||
(workspace / "secrets").mkdir()
|
||||
_ = (workspace / "secrets" / "key.txt").write_text("x", encoding="utf-8")
|
||||
set_path_denylist(["/secrets"])
|
||||
try:
|
||||
out = call_sync(tree, ".")
|
||||
assert "ok/" in out
|
||||
assert "secrets" not in out
|
||||
out2 = call_sync(tree, ".", apply_path_denylist=False, skip_dirs=[])
|
||||
assert "secrets/" in out2
|
||||
finally:
|
||||
set_path_denylist(None)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user