mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
Compare commits
32 Commits
55b25b5068
..
v0.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 36156043ca | |||
| 2cc5222dd3 | |||
| 0343e58dd9 | |||
| 7127190b9a | |||
| de03e5932b | |||
| 76933b044c | |||
| 2a6130495c | |||
| 9b365403d7 | |||
| 20fc493c47 | |||
| b967a000dc | |||
| 90174340d7 | |||
| c7441d4a43 | |||
| 1d05da62c2 | |||
| a9f58428bf | |||
| bbda658eab | |||
| ec228e9219 | |||
| d6c4aa0d4f | |||
| 221d5d5b68 | |||
| d697ecdaf6 | |||
| 836ac8d6f8 | |||
| 72d323d680 | |||
| 76e5b8ac0f | |||
| bbed7c87cd | |||
| dfd45a1d57 | |||
| 27bd7b46f7 | |||
| 6ec7b4de12 | |||
| 0790b5dd9f | |||
| a9f77a3b96 | |||
| f94d9a6f49 | |||
| cb35e3b105 | |||
| 9224241841 | |||
| 86d90bfeb1 |
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project overview
|
||||
|
||||
Plyngent is an LLM chat and agent toolkit (Python 3.14+, PDM-managed). Single-user CLI is usable: protocol clients, config, async memory, agent tool loop, workspace tools, and REPL/one-shot chat. Multi-tenant `router/` / web remain Phase H.
|
||||
Plyngent is an LLM chat and agent toolkit (Python 3.14+, PDM-managed). Single-user CLI is usable: protocol clients, config, async memory, agent tool loop, workspace tools, skill discovery, and REPL/one-shot chat. Multi-tenant `router/` / web remain Phase H.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -56,7 +56,7 @@ lmproto/deepseek/anthropic/ ← DeepSeek Anthropic client (extends Anthropic
|
||||
|
||||
### Config (`config/`)
|
||||
|
||||
TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]` section. Default path via platformdirs. Providers may set `timeout` as a float or `{ connect, read }` (`HttpTimeoutConfig`); omitted → product defaults (10s connect / 600s read). `DeepseekProvider.convention` (Literal: `""`/`"openai"`/`"anthropic"`/`"responses"`) selects the DeepSeek API surface — `"responses"` → OpenAI Responses, `"anthropic"` → Anthropic Messages on `https://api.deepseek.com/anthropic` (default url follows the convention); a per-model `ModelConfig.convention` overrides it (empty = inherit). `[mcp]` (`McpConfig`): `servers.<name>` stdio server definitions (`McpServerConfig`: `command`/`args`/`env`/`cwd`/`timeout`/`read_only`, `url` reserved for streamable HTTP) + `disable` gate; defining a server opts it in.
|
||||
TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]` section. Default path via platformdirs. Providers may set `timeout` as a float or `{ connect, read }` (`HttpTimeoutConfig`); omitted → product defaults (10s connect / 600s read). `DeepseekProvider.convention` (Literal: `""`/`"openai"`/`"anthropic"`/`"responses"`) selects the DeepSeek API surface — `"responses"` → OpenAI Responses, `"anthropic"` → Anthropic Messages on `https://api.deepseek.com/anthropic` (default url follows the convention); a per-model `ModelConfig.convention` overrides it (empty = inherit). `[mcp]` (`McpConfig`): `servers.<name>` stdio server definitions (`McpServerConfig`: `command`/`args`/`env`/`cwd`/`timeout`/`read_only`, `url` reserved for streamable HTTP) + `disable` gate; defining a server opts it in. `[skills]` (`SkillsConfig`): `paths` (explicit roots first), `discover` (`plyngent` user dir + `claude` dirs) with `include_project_roots`, `inject_catalog` / `max_catalog_skills`, `enabled`, and `allow_write` (standing write grant); a malformed section degrades to defaults like `[agent]` / `[mcp]`.
|
||||
|
||||
### Runtime (`runtime/`)
|
||||
|
||||
@@ -64,6 +64,10 @@ TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]
|
||||
|
||||
MCP (`runtime/mcp_client.py`): `McpManager` owns one `McpServerConnection` per `[mcp]` server — a spawned stdio subprocess speaking newline-delimited JSON-RPC 2.0: initialize handshake, `tools/list` (cursor pagination), `tools/call`, `notifications/tools/list_changed` → `tools_stale`. Spawn/handshake failures record `connection.error` (status string), never raise; `ensure_started` / `restart(config)` / `aclose` / `refresh_tools`; `statuses()` → `(server, status, tool_count)` rows; `instructions()` → `(server, text)` for every connected server that returned optional `InitializeResult.instructions` (usage guidance captured per connection); per-request timeout from config (default 30s).
|
||||
|
||||
### Skills (`skills/`)
|
||||
|
||||
Discovery and parsing, independent of the agent: `build_roots(config, workspace=…, user_config_dir=…, home=…)` resolves `[skills]` into ordered `SkillRoot`s (`config` explicit paths → `plyngent` user dir → `claude` user/project dirs), `SkillStore` scans each root for directories with a `SKILL.md` (a root may itself be one), parses the frontmatter subset (`frontmatter.py`), and caches the records against the directory list and its `SKILL.md` timestamps. `skills` returns the visible records sorted by name (first root wins), `shadowed()` the losing copies, `find(name)` exact-then-case-insensitive, `default_write_root()` our own directory (or the first root), `reload()` forces a rescan; `resolve_inside` / `resolve_skill_file` keep every read and write inside a skill directory (absolute paths and symlink escapes are refused). Records carry `issues` (unusable declared name, no description, unparseable frontmatter) so a repaired third-party skill stays visible instead of being silently fixed.
|
||||
|
||||
### Memory (`memory/`)
|
||||
|
||||
Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `ALTER` for new columns; `PRAGMA user_version` 4), default local user, sessions (bound to `workspace` path; optional `provider_name`/`model`; `todo_stack` + `access_grants` JSON columns), messages stored as msgspec chat message JSON.
|
||||
@@ -74,13 +78,13 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
||||
- **`responses_bridge` / `responses_dispatch`**: chat ↔ OpenAI Responses; stream text/reasoning deltas; tool calls + usage on `response.completed`.
|
||||
- **`messages_bridge` / `messages_dispatch`**: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments.
|
||||
- **Provider-side tools**: `OpenAIProvider.provider_tools` (list of dicts; default `[{type="web_search"}]` when omitted; `[]` disables) threaded via `ChatAgent`/`run_chat_loop` into Responses only; never executed by `ToolRegistry`.
|
||||
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name.
|
||||
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name. Before running, `execute` answers a call that omits a required argument with the accepted names (`_missing_required_error`) — the handler's bare `TypeError` never reaches the model, and no confirm prompt fires for a call that cannot run. Shape mistakes inside a *present* argument are the tool's own job (see the `chat` bullet).
|
||||
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`.
|
||||
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `provider_tools`; `retry()` continues incomplete turns (user-only **or** after committed tools — does not re-run those tools).
|
||||
- **`/compact`**: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first.
|
||||
- Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
|
||||
- Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`.
|
||||
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `allow_paths` (static out-of-workspace pre-allow `path → read|write|exec`; never prompts), `auto_continue_limits` (auto-raise tool/PTY limits without prompting; default false), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
|
||||
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `[skills] inject_catalog` (fold the skill catalog — name/source/description only — the same way), `allow_paths` (static out-of-workspace pre-allow `path → read|write|exec`; never prompts), `auto_continue_limits` (auto-raise tool/PTY limits without prompting; default false), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
|
||||
|
||||
### Tools (`tools/`)
|
||||
|
||||
@@ -88,17 +92,18 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
||||
|
||||
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots **or** a directory-access grant (mode-aware: `read` < `write` < `exec`, default required `write`; a file grant matches only that file); path substring denylist (wins over grants); command basename denylist (CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); escape error hints `request_directory_access`.
|
||||
- **`access`** (`tools/access.py`): `request_directory_access` (LOCAL|INSTANCE|SESSION) asks the host hook for out-of-workspace access; grants are one `path → mode` pair in one of three stores — `WorkspacePolicy.config_allow` (static `[agent].allow_paths`), `SessionState.access_grants` (human-approved, persisted on the session row), `WorkspacePolicy.yolo_allow` (`--yes`/`/yolo`, process only). `AccessDecision(mode, persist)` from the hook; denylisted paths can never be granted; cap `MAX_ACCESS_GRANTS`. Not a sandbox: `exec` grants (and any command) reach the whole FS.
|
||||
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range), `write_file`, `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; 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; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path`, `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume any truncated result via its `truncate_token=...`).
|
||||
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range), `write_file`, `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `regex_files` (Python `re` per line, not a glob; literal file/dir `path`, no glob expansion; skips VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path`, `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume any truncated result via its `truncate_token=...`).
|
||||
- **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_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. The prompt accepts `y`/empty (once), `n`, or `yyy` = stop asking for the rest of the turn (`cli.limits` module flag, reset by `run_turn_with_retries`, `/btw`, session switches, and chat exit). `--auto-continue` / `[agent] auto_continue_limits` set the process-wide default (covers one-shot/PTY); the flag does not imply YOLO and vice versa.
|
||||
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for shells/REPLs and `python|bash -c` one-liners (argv + code preview); deny may include a user comment for the model. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn).
|
||||
- **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems.
|
||||
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core); `wait` (line prompt with timeout; Enter disturbs).
|
||||
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core); `wait` (line prompt with timeout; Enter disturbs). Container args are shape-checked (`tools/chat/shape.py`): a mis-typed string `options`/`fields` (or a non-string `question`) returns an explained error — `options="a, b"` used to iterate characters into a per-character menu. `wait`'s `duration` accepts numeric strings (`"5"` = 5s) and rejects booleans/text/containers.
|
||||
- **`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`).
|
||||
- **`net` / `fetch`**: HTTP GET/POST/PUT/DELETE via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (`asyncio` `getaddrinfo`). Private/loopback hosts need instance policy grant (CLI timed confirm; **not** YOLO). Public cleartext HTTP and mutating methods soft-confirm (`YOLO`/`TRUSTABLE`). HTTPS→HTTP redirects blocked unless `allow_http_downgrade`. `user_agent` tool arg (or headers) is never replaced by the default when provided. Caps: body bytes/chars, request body size.
|
||||
- **`mcp`** (`tools/mcp.py`): register one server's advertised tools into the catalog as namespaced `mcp__<server>__<tool>` definitions with `ToolSource(kind="mcp", plugin_id=server)`; tags LOCAL (+ READ_ONLY when the server config sets `read_only`) — never YOLO/TRUSTABLE. `register_mcp_tools` purges prior MCP entries then re-registers every connected server (hosts call it on tool-registry rebuild); connections must already be started by `McpManager`.
|
||||
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + net (`fetch`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `get_truncated`, `listdir`, `glob_paths`, `grep_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `fetch`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch` accept GET only.
|
||||
- **`skills`** (`tools/skills.py`): discovery, reading, and writing of skill directories (`plyngent.skills` owns roots, the `SKILL.md` frontmatter subset, the mtime-cached store, and the inside-the-skill path rule). `skill_list` (names, source root, directory, description, shadowed copies, frontmatter notes), `skill_read` (a `SKILL.md` by name, or one bundled file — reuses `read_file` for ranges/line numbers/`truncate_token`), `skill_search` (regex over every visible skill via `regex_files`, hits rebased on `<skill>/<file>`), `skill_create` (new `SKILL.md` + optional `files`), `skill_edit` (replace / `append` / rewrite one file; a rewritten `SKILL.md` is re-parsed and notes a lost description). Reads need no grant (the host publishes the roots as `WorkspacePolicy.static_read`, so `read_file`/`regex_files` see them while writes stay denied); writes go through **`SkillWritePolicy`** on the instance — denylist first, then `SessionState.skill_write_roots` (a `session` answer), then `[skills].allow_write`, then the host's confirm hook (CLI `prompt_skill_write_confirm`: `o`nce/`s`ession/`n`o, timed, default deny, **not** skipped by YOLO); a host with no hook denies. Grants are per **skill directory**; `[skills].enabled = false` drops the tools from the registry (`SKILL_TOOL_NAMES`).
|
||||
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + skills + net (`fetch`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `get_truncated`, `listdir`, `glob_paths`, `regex_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `skill_list`/`skill_read`/`skill_search`, `fetch`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch` accept GET only.
|
||||
|
||||
### Prompting (`prompting.py`)
|
||||
|
||||
@@ -108,13 +113,13 @@ 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 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), `--auto-continue` (auto-raise limits), `--stream/--no-stream`, `--quiet`. Root `--log-level`. Enabled `[mcp]` servers start before the REPL (`_start_mcp_manager`, per-server failures warn on stderr) and are closed on exit (`McpManager.aclose`).
|
||||
- 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. `/mcp` (`list` default) prints per-server status/tool counts + an `instructions` preview when the server sent usage guidance, + last stderr lines; `/mcp reconnect` re-reads config, restarts every enabled server, and rebuilds the tool registry. `/grants` lists directory-access grants (`revoke <index|all>`; config grants point at the TOML); `/status` includes the grant count.
|
||||
- **`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), `--auto-continue` (auto-raise limits), `--stream/--no-stream`, `--quiet`. Root `--log-level`. Enabled `[mcp]` servers start before the REPL (`_start_mcp_manager`, per-server failures warn on stderr) and are closed on exit (`McpManager.aclose`); each server spawns in its own session/process group (`start_new_session` / `CREATE_NEW_PROCESS_GROUP`), so a terminal Ctrl+C does not kill it.
|
||||
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. `/history` is turn-oriented (`cli/transcript.py`): a turn runs from a user row to the next one, rows carry **display ordinals** (never DB row numbers; restart after `/clear`), the turn's ends (user + final answer) always print in full, `-v` expands every row (one-liners), `-vv` prints full bodies + local rows, `--preview` collapses every row, and `N`/`last [N]` address turns while `--message N` addresses one row. 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. `/skills` (`list` default) prints the discovered skills with their roots, shadowed copies, and frontmatter notes; `/skills search PATTERN` and `/skills read NAME [--file F]` run the matching tool handlers (the slash path binds the tool context itself); `/skills reload` rescans the roots and rebuilds tools. `/mcp` (`list` default) prints per-server status/tool counts + an `instructions` preview when the server sent usage guidance, + last stderr lines; `/mcp reconnect` re-reads config, restarts every enabled server, and rebuilds the tool registry. `/grants` lists directory-access grants (`revoke <index|all>`; config grants point at the TOML); `/status` includes the grant count and the visible skill count (or `off` when `[skills].enabled` is false).
|
||||
- `/btw` side questions: forked session; main transcript/DB untouched. `--tools=read` (default) exposes only `READ_ONLY` tools under `read_only_context()` (fetch=GET) with the main session view; `--tools=no` off; `--tools=full` whole-registry clone with fresh session. Tools off: default falls back to no tools; explicit `read`/`full` errors. The model gets an `aside` notice as its last message before the question (unsaved exchange + tool scope), shown to the user as `[notice] aside: …`.
|
||||
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**. A session resumed by a **freshly started process** (`--session` / default latest) also gets a `resume` notice about what a restart drops (PTY sessions, running commands, temp workspaces) — resuming inside a live process pushes nothing.
|
||||
- Host notices (`agent/notices.py`): system-level messages appended as trailing `developer` rows, never system-prompt edits — `Notice.to_message()` for the model, `.summary` for the user via `cli.display.echo_notice` (`--quiet` hides the user line only). Durable hosts persist them (`ChatAgent.push_notice`); a side agent without memory keeps its notice local. Trailing developer messages never end a turn, so `/retry` still sees an interrupted turn behind a notice or checkpoint.
|
||||
- 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 waits 5s/10s/15s/20s then +10s (10 attempts) and the budget **resets when an attempt completes a model round** (connection recovered); then `/retry`.
|
||||
- Pretty tool lines: known tools (`read_file`, `run_argv`, `grep_files`, …) print their `* Verb 'target' ` prefix as the call starts and append the outcome (`L1-4 (done)`, `(exit code 0)`) when the result lands, so a blocking tool stays visible while it runs. Interactive TTY only (`interactive_terminal()`): piped/plain output and `--verbose` keep whole lines / `[tool ok]`. A second call before the first result (parallel batch) erases the open prefix and falls back to whole lines.
|
||||
- Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels the in-flight turn (a session-scoped asyncio SIGINT handler routes it, `cli/interrupt.py` — a stray Ctrl+C while the loop waits never tears the REPL down); TTY confirms/ask prompts run off-loop and register their own SIGINT target, so Ctrl+C there cancels just the poll-based read (`wait` reports `cancelled by user`) or is ignored with a hint for readline prompts (`ask_user_*`, confirms) — never the turn, never the loop; auto-retry waits 5s/10s/15s/20s then +10s (10 attempts), its countdown is Ctrl+C-cancellable, and the budget **resets when an attempt completes a model round** (connection recovered); then `/retry`.
|
||||
- Pretty tool lines: every builtin tool prints its `* Verb 'target' ` prefix as the call starts and appends the outcome (`L1-4 (done)`, `(exit code 0)`, `(session 3)`) when the result lands, so a blocking tool stays visible while it runs; namespaced MCP tools share one generic line (`* MCP <server>:<tool> (done)`). Plugin tools and custom tool calls keep the `[tool] …` / `[tool ok] …` whole-line style. Interactive TTY only (`interactive_terminal()`): piped/plain output and `--verbose` keep whole lines. A second call before the first result (parallel batch) erases the open prefix — only that line, never the streamed line above it — and falls back to whole lines.
|
||||
- **`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.
|
||||
|
||||
|
||||
@@ -155,6 +155,16 @@ max_context_tokens = 200000
|
||||
# Auto-raise tool/PTY limits without prompting (same as answering yyy).
|
||||
# auto_continue_limits = false
|
||||
|
||||
# Optional skills (instruction directories). See doc/skills.md.
|
||||
# [skills]
|
||||
# enabled = true # false = no skill tools, no prompt catalog
|
||||
# paths = ["~/team-skills"] # explicit roots, highest priority
|
||||
# discover = ["plyngent", "claude"] # our user dir + other harnesses' dirs
|
||||
# include_project_roots = true # also <workspace>/.claude/skills
|
||||
# inject_catalog = true # fold name + description into the system prompt
|
||||
# max_catalog_skills = 50
|
||||
# allow_write = false # true = skip the per-write grant prompt
|
||||
|
||||
# Optional plugins (entry-point names); default load none. See doc/plugins.md.
|
||||
# [plugins]
|
||||
# enable = ["acme"]
|
||||
@@ -164,6 +174,8 @@ Per-provider **`timeout`** is passed to the HTTP session for chat/completions, R
|
||||
|
||||
Third-party **plugins**: install a package that declares `project.entry-points."plyngent.tools"` (and later other groups), then allowlist the entry-point name under **`[plugins].enable`**. Details: [doc/plugins.md](doc/plugins.md).
|
||||
|
||||
**Skills** (instruction directories with a `SKILL.md`): discovered under `[skills].paths`, our own user skills directory, and other harnesses' skill directories (`~/.claude/skills`, plus `<workspace>/.claude/skills` when `include_project_roots`). The catalog — name, source, description — is folded into the system prompt when tools are on; the model reads a `SKILL.md` with `skill_read`, searches with `skill_search`, and creates or updates skills with `skill_create` / `skill_edit` behind a timed `once`/`session`/`no` grant (`[skills].allow_write = true` for a standing one; the path denylist still wins and `--yes`/`/yolo` never skips it). Reading needs no grant: skill roots are read-only roots, so `read_file` / `regex_files` see them too while writes stay denied. Details: [doc/skills.md](doc/skills.md).
|
||||
|
||||
**MCP servers** (Model Context Protocol over stdio): define servers under `[mcp.servers.<name>]` (`command` + `args`, optional `env`/`cwd`/`timeout`/`read_only`) and their tools become agent tools namespaced `mcp__<server>__<tool>` (LOCAL tags; `read_only = true` also marks them READ_ONLY, eligible for `/btw --tools=read`). Names in `[mcp].disable` stay disconnected. A server may also return optional `instructions` (usage guidance) in its MCP `initialize` response; when tools are on those are folded into the agent system prompt as `MCP server <name> instructions:` blocks (`[agent] mcp_instructions = false` disables), and `/mcp` previews them. In the REPL, `/mcp` lists per-server status and tool counts; `/mcp reconnect` re-reads the config file and restarts every enabled server (adopts newly added ones).
|
||||
|
||||
Supported provider presets today:
|
||||
@@ -225,7 +237,7 @@ Sessions resume the **most recently updated** session for the current workspace
|
||||
|
||||
When a tool-loop or PTY limit is hit, the prompt accepts `y` (continue once), `n` (stop), or `yyy` — **stop asking for the rest of this turn** (the next user turn prompts again). `--auto-continue` / `[agent] auto_continue_limits = true` skip the prompt for every turn.
|
||||
|
||||
Tool calls show progress as they run: known tools (`read_file`, `run_argv`, `grep_files`, …) print a `* Read 'src/x.py' ` prefix the moment the call starts, and the outcome (`L1-80 (done)`, `(exit code 0)`) is appended once the result lands. Piped output and `--verbose` print the finished line as before.
|
||||
Tool calls show progress as they run: every builtin tool prints a `* Read 'src/x.py' ` prefix the moment the call starts, and the outcome (`L1-80 (done)`, `(exit code 0)`, `(session 3)`) is appended once the result lands; MCP tools render as `* MCP <server>:<tool> (done)`. Plugin tools keep the `[tool] …` / `[tool ok] …` style. Piped output and `--verbose` print the finished line as before.
|
||||
|
||||
### One-shot (scripts / CI)
|
||||
|
||||
@@ -256,8 +268,8 @@ Type `/help` in the REPL for the live list. Common ones:
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `/status` | Provider, session, context/usage estimates |
|
||||
| `/history [n\|last]` | Recent messages (default preview; `last`/`1` = full + markdown) |
|
||||
| `/history --full` | Full bodies for the selected window |
|
||||
| `/history [N \| last [N]]` | Conversation grouped by turn: default = the last turn (your message + the model's answer); `N` = turn N, `last N` = the last N turns |
|
||||
| `/history -v` / `-vv` | The turn's ends (your message, the answer) always print in full; `-v` adds every intermediate row (tool calls/results, notices) as a one-liner, `-vv` prints those in full too, and `--preview` collapses every row. `--message N` prints one message in full. Numbers are display ordinals shown by `/history`, never database rows (they restart after `/clear`) |
|
||||
| `/sessions` | Sessions for this workspace |
|
||||
| `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) |
|
||||
| `/export [md\|json] [path]` | Transcript from DB (no secrets) |
|
||||
@@ -272,12 +284,15 @@ Type `/help` in the REPL for the live list. Common ones:
|
||||
| `/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 |
|
||||
| `/skills [list\|search\|read\|reload]` | List discovered skills, regex-search them, print a `SKILL.md` (or one bundled file), or rescan the roots |
|
||||
| `/grants` | Directory-access grants: list, or `revoke <index\|all>` |
|
||||
| `/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 waits 5s, 10s, 15s, 20s, then +10s each step (10 attempts); an attempt that gets a full model round back **resets that budget**, so a connection that recovers and drops again starts over from 5s.
|
||||
|
||||
Ctrl+C cancels the in-flight turn, and during the auto-retry countdown it cancels the retry. Model-initiated prompts run off the event loop: a `wait` prompt is cancelled by Ctrl+C (the tool reports `cancelled by user` and the turn continues), while `ask_user_*` and confirm prompts ignore it with a one-line hint — answer them to continue. Ctrl+C never exits the REPL (use Ctrl+D or `/quit`), and MCP servers run in their own process group, so it never kills them either.
|
||||
|
||||
## Workspace model
|
||||
|
||||
- **Workspace** = root for file/process/VCS tools (default cwd).
|
||||
@@ -287,7 +302,9 @@ User messages are saved immediately. On API error or Ctrl+C, partial assistant/t
|
||||
|
||||
## Tools (when enabled)
|
||||
|
||||
Default registry: file ops (including `tree` with a markdown bullet-list default, `flat` paths or classic `decorated` on demand; default noise-dir skips), `run_argv` / `run_argv_batch` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), HTTP `fetch` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), `wait` (line prompt with timeout; Enter disturbs), `request_directory_access` (out-of-workspace access grants), `get_truncated` (resume any truncated result via its `truncate_token=...`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
|
||||
Default registry: file ops (including `tree` with a markdown bullet-list default, `flat` paths or classic `decorated` on demand; default noise-dir skips), `run_argv` / `run_argv_batch` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), HTTP `fetch` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), `wait` (line prompt with timeout; Enter disturbs), `request_directory_access` (out-of-workspace access grants), `get_truncated` (resume any truncated result via its `truncate_token=...`), skills (`skill_list` / `skill_read` / `skill_search` / `skill_create` / `skill_edit`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
|
||||
|
||||
**Skills** are instruction directories shared with other harnesses: a folder with a `SKILL.md` (YAML frontmatter — `name`, `description`, … — plus a Markdown body) and optional bundled files (scripts, references). Discovery, roots, grants, and the authoring format are documented in [doc/skills.md](doc/skills.md); the short version is that the catalog (name + description) goes into the system prompt, the body is read on demand with `skill_read`, and writing a skill is a separate grant (a timed `once`/`session`/`no` prompt, or `[skills].allow_write`) that is **not** a directory-access grant and is never skipped by YOLO.
|
||||
|
||||
Safety defaults:
|
||||
|
||||
@@ -300,6 +317,8 @@ Safety defaults:
|
||||
`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).
|
||||
|
||||
- Mis-typed tool arguments are answered, never guessed: a string where an array is expected (`run_argv` `argv`, `ask_user_choice` `options`, `ask_user_form` `fields`), a call that omits a required argument, or a non-numeric `wait` duration (numeric strings are accepted) returns an error showing the shape/names to send — the human never sees a garbage prompt, and no confirm prompt fires for a call that cannot run.
|
||||
|
||||
## Usage / context (CLI)
|
||||
|
||||
- **Context size** prefers API `prompt_tokens` from the last model call; otherwise a char-based estimate (~4 chars/token).
|
||||
|
||||
+5
-1
@@ -23,11 +23,15 @@
|
||||
- config: Plyngent configuration center (TOML), including ``[plugins]`` and
|
||||
``[networking]`` (e.g. fetch SSRF Fake-IP CIDR exemptions). Model entries
|
||||
may override provider ``preset`` / ``url`` for mixed API routing.
|
||||
``[skills]`` holds the skill roots, discovery sources, catalog limits, and
|
||||
the standing write grant.
|
||||
- runtime: ``create_client`` maps effective provider preset → protocol client.
|
||||
- skills: Skill discovery (ordered roots, ``SKILL.md`` frontmatter subset,
|
||||
mtime-cached store) and the read/write path rules the tools use.
|
||||
- agent: Kind-based tool loop, streaming, usage, compact; bridges:
|
||||
``responses_bridge``/``responses_dispatch``, ``messages_bridge``/``messages_dispatch``;
|
||||
``@tool`` / tags / registry. History stays chat-completions-shaped.
|
||||
- tools: Workspace file/process/VCS/chat/todo/net tools + directory-access
|
||||
- tools: Workspace file/process/VCS/chat/todo/net/skill tools + directory-access
|
||||
grants (`access`); catalog; plugins; instance/session context and views.
|
||||
- prompting: Shared ask/choose/form for CLI and tools.
|
||||
- cli: Click entry, slash registry, REPL, one-shot chat.
|
||||
|
||||
@@ -26,6 +26,24 @@
|
||||
# [networking]
|
||||
# ssrf_assume_public_cidrs = ["198.18.0.0/15"]
|
||||
|
||||
# Skills: instruction directories holding a SKILL.md (YAML frontmatter with at
|
||||
# least name/description, then a Markdown body) plus optional bundled files.
|
||||
# Discovery roots in priority order: explicit `paths`, our own user config
|
||||
# directory (<config dir>/skills), other harnesses' skill directories
|
||||
# (~/.claude/skills; plus <workspace>/.claude/skills when include_project_roots).
|
||||
# The catalog (name + description) is folded into the system prompt when tools
|
||||
# are on; the model reads bodies with skill_read and searches with skill_search.
|
||||
# Reading needs no grant (the roots are read-only roots); creating or editing a
|
||||
# skill asks once/session, or stands with allow_write = true. See doc/skills.md.
|
||||
# [skills]
|
||||
# enabled = true
|
||||
# paths = ["~/team-skills", "/srv/skills"]
|
||||
# discover = ["plyngent", "claude"]
|
||||
# include_project_roots = true
|
||||
# inject_catalog = true
|
||||
# max_catalog_skills = 50
|
||||
# allow_write = false
|
||||
|
||||
# MCP (Model Context Protocol) servers exposed as agent tools.
|
||||
# Defining a server opts it in; tools appear as mcp__<server>__<tool> with
|
||||
# LOCAL tags (confirm flow applies). Names in disable stay disconnected.
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
# Skills
|
||||
|
||||
A **skill** is a directory of instructions the agent can find, read, and follow:
|
||||
a `SKILL.md` (frontmatter + Markdown body) plus whatever bundled files it needs
|
||||
— scripts, references, templates. Plyngent discovers skills from your own user
|
||||
directory and from the directories other harnesses install into, so one skill
|
||||
folder can serve several agents.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
<root>/
|
||||
pdf-processing/
|
||||
SKILL.md # required: frontmatter + instructions
|
||||
scripts/extract.sh # optional bundled files
|
||||
references/forms.md
|
||||
```
|
||||
|
||||
`SKILL.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: pdf-processing
|
||||
description: Extract text and tables from PDFs; fill forms.
|
||||
version: 1
|
||||
allowed-tools: [run_argv, read_file]
|
||||
---
|
||||
|
||||
# PDF processing
|
||||
|
||||
Use `scripts/extract.sh <file>` for the text layer, then ...
|
||||
```
|
||||
|
||||
## Roots and discovery
|
||||
|
||||
Roots are scanned in priority order; the **first** skill with a given name wins
|
||||
and the copies it shadows stay listed (`skill_list`, `/skills`) instead of
|
||||
disappearing:
|
||||
|
||||
1. `[skills].paths` — explicit roots (highest priority)
|
||||
2. our own user directory: `<user config>/skills` (`plyngent config path` shows
|
||||
the directory that contains `plyngent.toml`)
|
||||
3. other harnesses: `~/.claude/skills`, and `<workspace>/.claude/skills` when
|
||||
`include_project_roots` is on (`discover = ["plyngent", "claude"]` by default)
|
||||
|
||||
A root may also *be* a skill directory (its own `SKILL.md`), which is handy when
|
||||
you point `paths` at a single skill. Missing roots are not an error; `/skills`
|
||||
lists the ones with no directory.
|
||||
|
||||
```toml
|
||||
[skills]
|
||||
enabled = true # false = no skill tools and no prompt catalog
|
||||
paths = ["~/team-skills"] # explicit roots; `~` expands to your home
|
||||
discover = ["plyngent", "claude"]
|
||||
include_project_roots = true
|
||||
inject_catalog = true # fold the catalog into the system prompt
|
||||
max_catalog_skills = 50
|
||||
allow_write = false # true = standing grant for skill writes
|
||||
```
|
||||
|
||||
## Frontmatter
|
||||
|
||||
The reader covers the YAML subset those files use — top-level `key: value`
|
||||
scalars (quoted, plain, `true`/`false`, integers), inline `[a, b]` lists, block
|
||||
`- item` lists, and `|` / `>` blocks. A plain scalar folds its indented
|
||||
continuation lines. A nested mapping (say a `metadata:` block) is skipped rather
|
||||
than guessed at, and a structurally broken block degrades to "no metadata" with
|
||||
a note: the skill stays listable instead of vanishing.
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `name` | Skill name; must be lowercase letters, digits, and single hyphens (max 64). A name that cannot be used falls back to the directory name, with a note. |
|
||||
| `description` | One line shown in the catalog and in `skill_list`. Missing → the first non-heading paragraph of the body. |
|
||||
| `version`, `license`, `allowed-tools`, … | Kept and shown by `skill_list`; `allowed-tools` is **recorded, not enforced** today. |
|
||||
|
||||
## What the agent sees
|
||||
|
||||
- **Catalog** (system prompt, `inject_catalog`): names, sources, descriptions —
|
||||
never the bodies, so a turn only pays for the skill it actually reads.
|
||||
- `skill_list` — what exists, where it came from, shadowed copies, and the notes
|
||||
from repaired frontmatter.
|
||||
- `skill_read NAME [file]` — the `SKILL.md` body (ranges, line numbers, and a
|
||||
continuation token like any other read) or one bundled file, resolved inside
|
||||
the skill directory only.
|
||||
- `skill_search PATTERN [skill]` — regex over every visible skill; hits come back
|
||||
as `<skill>/<file>:<line>: content`.
|
||||
|
||||
## Grants: reading vs writing
|
||||
|
||||
- **Reading needs no grant.** The host registers the roots as read-only roots, so
|
||||
`read_file`, `regex_files`, and `listdir` see skill files too. Writes are denied
|
||||
by the path policy — a skill directory never becomes a write grant.
|
||||
- **Writing needs a grant.** `skill_create` / `skill_edit` ask the human for the
|
||||
skill directory: `o` once, `s` for the rest of the chat, anything else (or a
|
||||
timeout) denies. The prompt is independent of YOLO: `--yes` / `/yolo` never
|
||||
skips it. `[skills].allow_write = true` is the standing grant, and the path
|
||||
denylist (`[agent].path_denylist`) still wins over everything.
|
||||
|
||||
`skill_create` writes a new `SKILL.md` (name, description, body) plus any
|
||||
`files` you list; `skill_edit` changes one file by literal replacement
|
||||
(`old_string` → `new_string`, all matches with `replace_all`), by `append`, or
|
||||
by rewriting it. A rewritten `SKILL.md` is re-parsed, and the reply says so when
|
||||
the description (or the whole frontmatter) is gone.
|
||||
|
||||
## Care and safety
|
||||
|
||||
- A skill is **data, not code**: nothing in a skill body bypasses the confirm
|
||||
flow, the path denylist, or the command denylist. Bundled scripts run through
|
||||
the normal process tools, so they still need whatever grant those require.
|
||||
- Third-party skills (other harnesses' directories, shared repos) are untrusted
|
||||
input. Read before you follow, keep `paths` pointed at directories you trust,
|
||||
and prefer `allow_write = false` + per-write prompts when a skill comes from
|
||||
outside.
|
||||
- Keep skills small and specific; long bodies are better split into a short
|
||||
`SKILL.md` plus reference files the model reads on demand.
|
||||
@@ -171,51 +171,51 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.5.5"
|
||||
version = "3.5.6"
|
||||
requires_python = ">=3.10"
|
||||
summary = "Lightweight in-process concurrent programming"
|
||||
groups = ["default"]
|
||||
files = [
|
||||
{file = "greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b"},
|
||||
{file = "greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404"},
|
||||
{file = "greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f"},
|
||||
{file = "greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24"},
|
||||
{file = "greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -242,43 +242,58 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "jh2"
|
||||
version = "5.0.14"
|
||||
version = "5.0.15"
|
||||
requires_python = ">=3.7"
|
||||
summary = "HTTP/2 State-Machine based protocol implementation"
|
||||
groups = ["default"]
|
||||
files = [
|
||||
{file = "jh2-5.0.14-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a1ee369128cee3a78be7ade531e7229480c22b20b2371671cfe566f34b47e079"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cfbad7863fcb8dc66dbc9da3e9ccfe11f8944d043618d486fdfd12b1ab54f6a"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9087585bd1f8c11fbade60164b9e79f42323784b22be245ceaeca758422e6bc5"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ccbfa14b306d5c5a4a68f3fe9823d686e46406c7dba5babcccb198fd5ae53a11"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5480d38a0eeb43f2bce716aaddee2b89fe30e24b9ac5b7ecfb8cbc7fb822c257"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:348824676428d44d6ddceadb2c1d0885cc351ebe0378eadfa74e382c5359abbe"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e09fb3bb222ce3cce63bcfab99cb48cbfdc44f56e81369bb1f920ef02c1105f"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:20f9b15a367961dbbedeb0f40380668141d3543f427246456f337a6e9fbb5ab2"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3f60ba14d122c0c72b0da283c69e1c0e717fe18c32b8fbd13202954d5d5c7f4b"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:9e168d952ac659837c758e206efc7aef2a8b4eb57389691a2675f9059bdbb7d7"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:dedaf4cc07aee718c4380fcb0bea231f205be6db45a0855454b1d467a0c86837"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:9fc37f6fbeaba1fd5dd686594655448e2c98fbbb87b2a387faf5a331b2bc85b6"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-win32.whl", hash = "sha256:8b4d38bd4bfb3cd2fd0eec62e95e14efd3c97b50535216acbc30afea6b5963e1"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-win_amd64.whl", hash = "sha256:56c3e595254d253657a640bb1f92711836676a654a64484f6b1f1112ec52e68a"},
|
||||
{file = "jh2-5.0.14-cp314-cp314t-win_arm64.whl", hash = "sha256:391a5357ac82a27d72579ccb9bcb281fd58da0c76e702158416ecabaa65fc3e2"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:68a23b2b8c1750b8bd208d7e5be0fce20f96aca795f57c56e525a36470773625"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0c2a4501c2a8ae3db2228c9138dc4a4b047750ffab018c29fbf58de59334bd8"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c32cc7136c54a63fc04abbdb656cd37dcd6b8462b946cd072ebdfbcf9350be45"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c9f39c49b4b02b82202fe9b983f22f1f2aa685ae96ee5800a9b4cad511c07b68"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20517ff67db8ac5c3af0bafffe6a639721d8be32f7a9a7c2b25f0ad7f74d2c85"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4df556fe49b596774af0f49f78eff1e143448f8367609d3e6d2a50533ada3855"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3e92cd9d01351d14b15c826120bfb41f5834bb0a8860cbaa1779f557d3e1288"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a6eeb6cff0f5fcd529d7e0afc75eb81ad5befd5b6b893c20f188fb7f467a0320"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:50ef70513db51c8a078e09b0bd824b0a9e3ffd92ee20122c4fa128d3a33b3625"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:52d105593e3af3384119f398682ed29fcbf79c9e87d637f8f49baaeb10adc0d4"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:bda792840b62a41bf24049849a38b9355096f1a03a82cc7bc5309e6e85789b1c"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:fddbfe1dd7c55160431975863b415b124ba408c20a8dab6ff3f2bf1eb18c3f8e"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-win32.whl", hash = "sha256:c41cfd834a360e3eb7b1e3d18b9d1debb16e001e08177d422f65293bd1e586aa"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-win_amd64.whl", hash = "sha256:1008bd1c05b879e33a9450823a67066716dd430f96a872dc652266c2271d7615"},
|
||||
{file = "jh2-5.0.14-cp37-abi3-win_arm64.whl", hash = "sha256:b5c82c7045e83e89e4b59d2940c456365bd7ad7e49650ae5a977cacdd4a2354d"},
|
||||
{file = "jh2-5.0.14-py3-none-any.whl", hash = "sha256:070895dfaeda94eccbdc45c7c97985d1c6ac339de7c4e329106b6f9450189bda"},
|
||||
{file = "jh2-5.0.14.tar.gz", hash = "sha256:101708db41998159295c403f891dce9d8055a8dd98450c605e6eb7c34751543e"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:855dea668a30eab826ecdbc07b69b19e8daedeb1f0519e02759a6b37426354c5"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd129c07f30387463e634d7aec629da31b041219e17c6d42fc7a7641e1e7ef8b"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f702abcd2e3666d54f721629ec921a1e698357e28f6348af7b4d9d95b89fe37"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:9698673b2cccac7a27dd4ae48d6f4936c998a7962043049783dab4fd03e65084"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9695b0f3c696cb87b851b3f28d5dc81d8932c81a1547713913266b88ee99e37a"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d71f6aa2c97f8042669102200a1a2dc1241e0fb5044cc14032d0321936f85cd0"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea4ac921ddd2d519646df9b94f96e2cd55529f92f6d7ff4c65508ca2f65399ef"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f0257484827212950bb4624dbfdf70d713dbaed5b1c489151b41f525fea3c1"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7f1f45e3a7f4b13ec10e9a55d1188fb64f090a24fd847ad124fde4da3682e8ab"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:1a7f6ee93c57672f7bd7c3e2c2f841a35c1a0c3571c39abc674e125f344242fa"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-musllinux_1_1_i686.whl", hash = "sha256:7bddf19067d411e900cc19fdfee566cc3bc0aa1b76d07666202a8fcbf2c980e5"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:bfd24d34ed13b5943d5d548a86fd0efaa5029aaa6615932e677ea14d1a47324b"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-win32.whl", hash = "sha256:03ef4cea5c924a89d3747fa5eca768763e727326ddf8d2ee20b66f4cd2942161"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-win_amd64.whl", hash = "sha256:5a32cd4bb2fc7f03315b3415b0d1f37b204f4334b352e68c25e3e31f0750b7cc"},
|
||||
{file = "jh2-5.0.15-cp311-abi3-win_arm64.whl", hash = "sha256:4d0b98c5c2f27d7dfcec835960f9e4c76c7936f4bad1c94ab323f8f0831295c6"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:136f1f06d644e105ace8e830a55c66bb6be15355af427c013cefabbb4360889c"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7aae38ee0dea5f3af2a918597df28f7659d1e82d1795605ee46c5d291d7e8ff"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34f864522d4fafd8a61bf40dc01e2a93840e6c1a17dba37db008529be22ef40a"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:783463574dcac3a6d8ae948e2110bb1b46099c79b919912c88360d21b7c91b5c"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d6069850eb0febdcdd649a4f5dbe74560ea6219ddce467808b4b4bf532c14e3"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6af18cc6b061b5830b121fff1cc876e864e881909c446fd606903e64a9796567"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aaf2dd05d85289cf83641f3c4f54947a0168fa81c46fc9038ea9d241caad50e"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bad01872ec803e67d91228fe5f040175c86bbdb0b345be4ce0c5ccd620a220a0"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:c201b89f9d15f563fe4d6dad0738ce02641636c13b141096ad7497aa7bd95b3c"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:b43d69d715f77d840d5600842a179d8175625bcb5f5d8dbe9bc7ed71dcb43696"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:4a511057fb3d8bc6cb76eed0645a8a446e870640d9250144f0d403007383e2c3"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c4a2c16fae658a2f7e67d1af3f1cfaf03003cb2f6d9a8c1d3f237b337614d568"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-win32.whl", hash = "sha256:36aeaa24c9af2a2ac0830523721d0fadad5602842d662aa0c5391b89ea3e7d1e"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-win_amd64.whl", hash = "sha256:a88320003a21783b5ab5c605ba4b687f836b46dadedc55cfc3e64ca88c41b81d"},
|
||||
{file = "jh2-5.0.15-cp314-cp314t-win_arm64.whl", hash = "sha256:7eb050ada76d078cf27b7660451c0ee7b38e3f770222d852cc34d2f4cf0bc782"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:06cd5acc4ff28ac27cca5c2f0985d259e81bc29b845c521aae04440a369a00ae"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8912f22c97cdcb00edce434b61fa1d05af41ad07deb6bb1ed9e06348df11eea"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d28771bccaa6944c4e606a44b1035ea11b2d684bd2c7c195ee3c3a9afd84443f"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1e0fda6e8a7b7364077a03749321f3bb9f01aa369ab659f63d241860f4b66bbb"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6596a9d05f049f783326e927b5363ebe0b1da6716835237ae32b642529b405ac"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29a0014ee53ffc8bf0581dd77270e25329397c55f4724bb18fddef9a105177e5"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cd7e4d259eab50e076e4b30ecbd937e55b319a58ad75642bdffb401455520b7"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:97299e27743ebf52d9544e596165700407b8b46394870dc374d6fd49c3251084"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:95443c602d6e7fa8aa5e49d19381cb5a39d53b79a1678e90a4c39f4f0f0542e5"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:13fa1a5e98a8c8f55405e2e7c6f6a1e005d01a48f7510826ff34939f624b1ad6"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:aa99840f9222928b0c9c0dd2b0c0308f5d1be8f6815bbf6f820746c51de54dd7"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:723a0a4c5ac0e61c94cb275118c7a6a6aaf5fd56084b02632f4b080cc83c6691"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-win32.whl", hash = "sha256:fa1c3885ed090c65054e4bbd983a407df0436121b1a841ba7fe1f678a7982c65"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-win_amd64.whl", hash = "sha256:8c5850335fe11078b42fab606e5ac4a68c6ee8c6799864badc464f7839f06ec2"},
|
||||
{file = "jh2-5.0.15-cp37-abi3-win_arm64.whl", hash = "sha256:9493d5a20f6ba04dc285e3580bcffe64c0d61660c85aee7fc19e9adbaea55ef7"},
|
||||
{file = "jh2-5.0.15-py3-none-any.whl", hash = "sha256:0ed7246846e68109fef261fdb00534ca5af3363d48dbf881d06a2d0e3a814e99"},
|
||||
{file = "jh2-5.0.15.tar.gz", hash = "sha256:53fb377caca50441fd1892f60745c44d3ba8c43844c3792e73c34bf9f841adaf"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -382,13 +397,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.11.7"
|
||||
version = "4.11.12"
|
||||
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.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6"},
|
||||
{file = "platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d"},
|
||||
{file = "platformdirs-4.11.12-py3-none-any.whl", hash = "sha256:b1ba966ac76153d9985a0787ffab951e7c18416189873d4e4ddb2c8a04a0a254"},
|
||||
{file = "platformdirs-4.11.12.tar.gz", hash = "sha256:e8dc1cb58f1153fd7f61db1374317770baababec2480b37b8f01c6cc25b45267"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -553,7 +568,7 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.52"
|
||||
version = "2.0.54"
|
||||
requires_python = ">=3.7"
|
||||
summary = "Database Abstraction Library"
|
||||
groups = ["default"]
|
||||
@@ -563,16 +578,22 @@ dependencies = [
|
||||
"typing-extensions>=4.6.0",
|
||||
]
|
||||
files = [
|
||||
{file = "sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9"},
|
||||
{file = "sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e"},
|
||||
{file = "sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437"},
|
||||
{file = "sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8"},
|
||||
{file = "sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd"},
|
||||
{file = "sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2"},
|
||||
{file = "sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d"},
|
||||
{file = "sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582"},
|
||||
{file = "sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89"},
|
||||
{file = "sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:abd6b21bc58e91c1932eb5d6d7f1bd44a551dfec7b6a7f517c3638ccd67233a0"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5417322b3c025dd82918725d3bf09ec105fac95efc195722b8b06e1d9c381139"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f84099e4b04a5c2d44500a2a8302eee5af4bc6fee63e8c6e9cf6786e747280e"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a0956dc754d3884da7fe60097110ec7a8a105d26afa2f0844468f4b1598c6912"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:87ba8834318b0d8dc94fc6f405d071b5c08be32a6c3fd68107fd6952ee949615"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-win32.whl", hash = "sha256:842540e4382472f23c79589995752648d14696a8200d0807ed8c5c59c92ade44"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-win_amd64.whl", hash = "sha256:f4e8f955d13af83fb4e35c3472e5377ee22d3445eada1e5e48199588edb69835"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ca05f4e7852cf48083b0cf157e4f9504b7068780422a50fa82f45353b8c5e14a"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a8b6417cbb7b735cf91c2b59453c2a554cefa0a8d7bd15aa35740739410d77"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e55a0b96a1577a1e108c91ccdeeb9cd92768f28ce206597311c3bf6d6423abd"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:69cab115c40fd02c5a22c68e4ee630fa6ef9a1650f1de944419aab1f7096fc4f"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e08397c6c42f53b2488acde9108b8bfefd52d7afd1bf2f03d2ffcab7a204aceb"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-win32.whl", hash = "sha256:b9086b8ad48280ef6a7ba68262d5e44f7db1c4cb1973e8cdae8a9f467ae66f51"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-win_amd64.whl", hash = "sha256:b67c1744e453af833667fc1b84de07adb4a64f3536ef52a8ec5ac2b941d43970"},
|
||||
{file = "sqlalchemy-2.0.54-py3-none-any.whl", hash = "sha256:7e33a631ab1474f8fe6b910bd1a07b7b8009c4c78cdd3fb18001b03e3bc2e1d2"},
|
||||
{file = "sqlalchemy-2.0.54.tar.gz", hash = "sha256:baa8521e8ee9f24e75dfc7aaabc08020e551ef0d48d7c3e3536f5cddf277586b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -599,7 +620,7 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3-future"
|
||||
version = "2.24.907"
|
||||
version = "2.25.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"]
|
||||
@@ -609,8 +630,8 @@ dependencies = [
|
||||
"qh3<3.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.24.907-py3-none-any.whl", hash = "sha256:6f9007bd9c6d3ea13d8fe70e5d4b87a6d15638a849bd2227ab5cbdfbe5d1e842"},
|
||||
{file = "urllib3_future-2.24.907.tar.gz", hash = "sha256:6565b3cc160a950821953079a6ccd4daa8fd60071a13c9a251576648929f3aa7"},
|
||||
{file = "urllib3_future-2.25.900-py3-none-any.whl", hash = "sha256:1d4809b9714aef7f35338b7d3a388b0280aefa41d2edb78ba48de91309d014c8"},
|
||||
{file = "urllib3_future-2.25.900.tar.gz", hash = "sha256:0583bd313d223cb0d92367fe9bf07e7c8d926eebb0bdf6960670102e7ad4b6c1"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "plyngent"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
description = "LLM chat and agent toolkit"
|
||||
authors = [
|
||||
{ name = "HivertMoZara", email = "worldmozara@163.com" },
|
||||
|
||||
@@ -22,6 +22,8 @@ use `run_argv_batch` for ordered pipelines.
|
||||
- `edit_replace`: fix match / `max_replaces`; `edit_lineno` edits only lines read via \
|
||||
`read_file(with_lineno=true)`, and line numbers reset after any file write.
|
||||
- `read_file` results start with `L{begin}-{end}` (1-based lines; offset is 0-based).
|
||||
- `regex_files`: `pattern` is a Python `re` regex (not a glob, so `*.py` fails) and \
|
||||
`path` is a literal file/dir (no glob expansion).
|
||||
- Truncated results carry a `truncate_token=...`; continue with `get_truncated`.
|
||||
- Prefer `fetch` for HTTP(S); private/LAN hosts need human policy allow (not YOLO).
|
||||
- PTY secrets only via `ask_into_pty`; denylists and confirms still apply.
|
||||
|
||||
@@ -201,6 +201,30 @@ def tool[**PS, R](
|
||||
return decorator
|
||||
|
||||
|
||||
def _missing_required_error(definition: ToolDefinition, args: Mapping[str, object]) -> str | None:
|
||||
"""Model-facing error when a call omits a required argument.
|
||||
|
||||
The JSON schema already declares what is required, so the model gets the
|
||||
exact argument names back instead of the handler's bare ``TypeError``
|
||||
(``missing 1 required positional argument``). Unknown *extra* arguments are
|
||||
left to the handler: a declared schema is not proof that no other key is
|
||||
accepted (MCP servers may document only part of their input).
|
||||
"""
|
||||
schema = definition.parameters
|
||||
required = schema.get("required")
|
||||
if not isinstance(required, list):
|
||||
return None
|
||||
missing = [name for name in cast("list[object]", required) if isinstance(name, str) and name not in args]
|
||||
if not missing:
|
||||
return None
|
||||
properties = schema.get("properties")
|
||||
accepted: list[str] = []
|
||||
if isinstance(properties, dict):
|
||||
accepted = sorted(str(key) for key in cast("dict[object, object]", properties))
|
||||
hint = f"; it accepts: {', '.join(accepted)}" if accepted else ""
|
||||
return f"error: `{definition.name}` is missing required argument(s): {', '.join(missing)}{hint}"
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""Name → tool definition map with execution helpers."""
|
||||
|
||||
@@ -434,6 +458,11 @@ class ToolRegistry:
|
||||
if not isinstance(raw_args, dict):
|
||||
return "error: tool arguments must be a JSON object"
|
||||
args = {str(key): value for key, value in cast("dict[object, object]", raw_args).items()}
|
||||
# Argument shape/coverage problems are answered before any confirm prompt:
|
||||
# the human should never be asked about a call that cannot run.
|
||||
missing_error = _missing_required_error(definition, args)
|
||||
if missing_error is not None:
|
||||
return missing_error
|
||||
|
||||
async def _run() -> str:
|
||||
missing = self._check_state_tags(definition)
|
||||
|
||||
+14
-1
@@ -17,7 +17,11 @@ from plyngent.cli.editor import (
|
||||
resolve_config_path,
|
||||
)
|
||||
from plyngent.cli.exit_codes import EXIT_CANCELLED, EXIT_OK, EXIT_TURN_FAILED
|
||||
from plyngent.cli.interrupt import install_keyboard_interrupt_sigint
|
||||
from plyngent.cli.interrupt import (
|
||||
install_keyboard_interrupt_sigint,
|
||||
install_sigint_router,
|
||||
uninstall_sigint_router,
|
||||
)
|
||||
from plyngent.cli.limits import install_cli_limit_hooks
|
||||
from plyngent.cli.repl import run_repl
|
||||
from plyngent.cli.retry import run_user_text_with_retries
|
||||
@@ -251,6 +255,11 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
||||
yolo: YoloMode | None = "on" if yes else None
|
||||
|
||||
memory = await MemoryStore.open(_database_config(store, quiet=quiet or oneshot))
|
||||
# A SIGINT must never surface as a KeyboardInterrupt raised in the event
|
||||
# loop's own frame (it would tear this run — and the shutdown below — down
|
||||
# half-finished). The router cancels the innermost registered target instead
|
||||
# and stays installed through cleanup.
|
||||
_ = install_sigint_router()
|
||||
try:
|
||||
from plyngent.cli.provider_recovery import ensure_provider_ready
|
||||
|
||||
@@ -378,6 +387,9 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
||||
if isinstance(state_obj, ReplState):
|
||||
state_obj.instance_state.workspace.policy_confirm_hook = None
|
||||
state_obj.instance_state.workspace.policy_allowed_commands.clear()
|
||||
from plyngent.tools.skills import set_skill_write_policy
|
||||
|
||||
set_skill_write_policy(None, instance=state_obj.instance_state)
|
||||
from plyngent.tools.net import (
|
||||
clear_private_grants,
|
||||
clear_ssrf_assume_public_cidrs,
|
||||
@@ -402,6 +414,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
|
||||
PtyManager.close_all()
|
||||
uninstall_sigint_router()
|
||||
|
||||
|
||||
def _configure_logging(level: str) -> None:
|
||||
|
||||
+460
-50
@@ -5,6 +5,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
@@ -31,6 +32,9 @@ if TYPE_CHECKING:
|
||||
|
||||
_TOOL_RESULT_PREVIEW = 120
|
||||
_TOOL_ARGS_PREVIEW = 80
|
||||
_ASK_SUBJECT_PREVIEW = 60
|
||||
_MCP_TOOL_PREFIX = "mcp__"
|
||||
_SKILL_MAIN_FILE = "SKILL.md"
|
||||
|
||||
# Process/session display flags (set from ReplState / slash).
|
||||
_verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False)
|
||||
@@ -123,6 +127,33 @@ def _json_list_arg(args_json: str, key: str) -> list[str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _json_int_arg(args_json: str, key: str) -> int | None:
|
||||
value = _json_arg(args_json, key)
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
return None
|
||||
|
||||
|
||||
def _status_fields(result: str) -> dict[str, str]:
|
||||
"""Parse the leading ``key=value`` block of a structured tool result.
|
||||
|
||||
The block ends at the first ``--- <payload> ---`` marker (``run_argv``,
|
||||
``fetch``, ``read_pty``).
|
||||
"""
|
||||
fields: dict[str, str] = {}
|
||||
for line in result.splitlines():
|
||||
if line.startswith("--- "):
|
||||
break
|
||||
if "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
fields[key] = value
|
||||
return fields
|
||||
|
||||
|
||||
def _pretty_prefix(text: str) -> str:
|
||||
"""Style the leading ``* Verb 'target' `` segment of a pretty tool line."""
|
||||
return click.style(text, fg="yellow")
|
||||
@@ -163,6 +194,14 @@ def _read_file_prefix(args_json: str) -> str:
|
||||
return _pretty_prefix(f"* Read '{path}' ")
|
||||
|
||||
|
||||
def _range_detail(result: str) -> str | None:
|
||||
"""``L{begin}-{end} (done)`` detail of a ranged result, when it carries one."""
|
||||
first = result.splitlines()[0] if result else ""
|
||||
if first.startswith("L") and "-" in first:
|
||||
return _pretty_segments((f"{first} ", "dim"), ("(done)", "green"))
|
||||
return None
|
||||
|
||||
|
||||
def _read_file_detail(_args_json: str, result: str) -> str:
|
||||
"""``read_file`` outcome: range/done, or which failure kind occurred."""
|
||||
if result.startswith("error: file not found"):
|
||||
@@ -171,16 +210,21 @@ def _read_file_detail(_args_json: str, result: str) -> str:
|
||||
return _pretty_segments(("(not a file)", "red"))
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments(("(error)", "red"))
|
||||
lines = result.splitlines()
|
||||
first = lines[0] if lines else ""
|
||||
if first.startswith("L") and "-" in first:
|
||||
return _pretty_segments((f"{first} ", "dim"), ("(done)", "green"))
|
||||
return _pretty_segments(("(done)", "green"))
|
||||
return _range_detail(result) or _pretty_segments(("(done)", "green"))
|
||||
|
||||
|
||||
_TODO_LABELS: dict[str, str] = {
|
||||
"todo_list": "Todo List",
|
||||
"todo_push": "Todo Push",
|
||||
"todo_pop": "Todo Pop",
|
||||
"todo_update": "Todo Update",
|
||||
"todo_clear": "Todo Clear",
|
||||
}
|
||||
|
||||
|
||||
def _todo_line(name: str) -> PrettyLine:
|
||||
"""``todo_push`` / ``todo_update``: header first, rendered stack as the detail."""
|
||||
label = "Todo Push" if name == "todo_push" else "Todo Update"
|
||||
"""Todo tools: header first, the rendered stack as the detail."""
|
||||
label = _TODO_LABELS[name]
|
||||
|
||||
def prefix(_args_json: str) -> str:
|
||||
return click.style(f"* {label}:", fg="yellow")
|
||||
@@ -288,14 +332,14 @@ def _glob_detail(_args_json: str, result: str) -> str:
|
||||
return _pretty_segments((f"({count} {unit})", None))
|
||||
|
||||
|
||||
def _grep_prefix(args_json: str) -> str:
|
||||
def _regex_prefix(args_json: str) -> str:
|
||||
pattern = _json_str_arg(args_json, "pattern") or "?"
|
||||
path = _json_str_arg(args_json, "path") or "."
|
||||
return _pretty_prefix(f"* Grep '{pattern}' in '{path}' ")
|
||||
return _pretty_prefix(f"* Search '{pattern}' in '{path}' ")
|
||||
|
||||
|
||||
def _grep_detail(_args_json: str, result: str) -> str:
|
||||
"""``grep_files`` outcome: matches and file count, or none/error."""
|
||||
def _regex_detail(_args_json: str, result: str) -> str:
|
||||
"""``regex_files`` outcome: matches and file count, or none/error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
if result == "(no matches)":
|
||||
@@ -317,13 +361,7 @@ def _run_argv_detail(_args_json: str, result: str) -> str:
|
||||
"""``run_argv`` outcome: exit code, timeout, or error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
fields: dict[str, str] = {}
|
||||
for line in result.splitlines():
|
||||
if line.startswith("--- "):
|
||||
break
|
||||
if "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
fields[key] = value
|
||||
fields = _status_fields(result)
|
||||
if fields.get("timed_out") == "true":
|
||||
return _pretty_segments(("(timed out)", "red"))
|
||||
code = fields.get("exit_code", "")
|
||||
@@ -363,13 +401,7 @@ def _fetch_detail(_args_json: str, result: str) -> str:
|
||||
"""``fetch`` outcome: HTTP status, or error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
status = ""
|
||||
for line in result.splitlines():
|
||||
if line.startswith("--- "):
|
||||
break
|
||||
if line.startswith("status="):
|
||||
status = line.partition("=")[2]
|
||||
break
|
||||
status = _status_fields(result).get("status", "")
|
||||
return _pretty_segments((f"({status or 'done'})", _http_status_fg(status)))
|
||||
|
||||
|
||||
@@ -413,6 +445,10 @@ def _mutator_line(name: str) -> PrettyLine:
|
||||
)
|
||||
|
||||
|
||||
def _vcs_kind_prefix(_args_json: str) -> str:
|
||||
return _pretty_prefix("* VCS Kind ")
|
||||
|
||||
|
||||
def _vcs_status_prefix(_args_json: str) -> str:
|
||||
return _pretty_prefix("* VCS Status ")
|
||||
|
||||
@@ -428,6 +464,15 @@ def _vcs_log_prefix(_args_json: str) -> str:
|
||||
return _pretty_prefix("* VCS Log ")
|
||||
|
||||
|
||||
def _vcs_diff_prefix(args_json: str) -> str:
|
||||
staged = _json_arg(args_json, "staged") is True
|
||||
return _pretty_prefix("* VCS Diff (staged) " if staged else "* VCS Diff ")
|
||||
|
||||
|
||||
def _vcs_branch_prefix(_args_json: str) -> str:
|
||||
return _pretty_prefix("* VCS Branch ")
|
||||
|
||||
|
||||
def _vcs_log_detail(_args_json: str, result: str) -> str:
|
||||
"""``vcs_log`` outcome: commit count, none, or error."""
|
||||
if result.startswith("error:"):
|
||||
@@ -444,23 +489,321 @@ def _wait_prefix(_args_json: str) -> str:
|
||||
|
||||
|
||||
def _wait_detail(_args_json: str, result: str) -> str:
|
||||
"""``wait`` outcome: slept duration, disturbed, or error."""
|
||||
"""``wait`` outcome: slept duration, disturbed, cancelled, or error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
if result.startswith("waited "):
|
||||
return _pretty_segments((f"({result.removeprefix('waited ')})", "green"))
|
||||
if result.startswith("cancelled by user"):
|
||||
return _pretty_segments(("(cancelled)", "yellow"))
|
||||
return _pretty_segments(("(disturbed)", "yellow"))
|
||||
|
||||
|
||||
# Pretty tools: ``prefix`` prints as the call starts, ``detail`` when it lands.
|
||||
# PTY sessions share one prefix shape: ``* PTY <verb> <session> ``.
|
||||
def _pty_session_label(args_json: str) -> str:
|
||||
session_id = _json_int_arg(args_json, "session_id")
|
||||
return "?" if session_id is None else str(session_id)
|
||||
|
||||
|
||||
def _pty_session_line(verb: str, detail: Callable[[str, str], str]) -> PrettyLine:
|
||||
"""``* PTY <verb> <session> `` plus the verb's own outcome."""
|
||||
return PrettyLine(
|
||||
prefix=lambda args_json: _pretty_prefix(f"* PTY {verb} {_pty_session_label(args_json)} "),
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
def _open_pty_prefix(args_json: str) -> str:
|
||||
argv = _json_list_arg(args_json, "command")
|
||||
cmd = shlex.join(argv) if argv else "?"
|
||||
return _pretty_prefix(f"* PTY Open $ {cmd} ")
|
||||
|
||||
|
||||
def _open_pty_detail(_args_json: str, result: str) -> str:
|
||||
"""``open_pty`` outcome: the new session id, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
session_id = _status_fields(result).get("session_id", "")
|
||||
return _pretty_segments((f"(session {session_id or '?'})", "green"))
|
||||
|
||||
|
||||
def _read_pty_detail(args_json: str, result: str) -> str:
|
||||
"""``read_pty`` outcome: exit status, matched wait, payload size, or error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
fields = _status_fields(result)
|
||||
data = result.split("--- data ---", 1)[1].removeprefix("\n") if "--- data ---" in result else ""
|
||||
count = len(data.splitlines())
|
||||
if fields.get("alive") == "false":
|
||||
code = fields.get("exit_code", "")
|
||||
return _pretty_segments((f"(exited {code})" if code else "(exited)", "green"))
|
||||
unit = "line" if count == 1 else "lines"
|
||||
if _json_str_arg(args_json, "until") and fields.get("matched") == "true":
|
||||
return _pretty_segments((f"(matched, {count} {unit})", "green"))
|
||||
if count == 0:
|
||||
return _pretty_segments(("(alive, no output)", "dim"))
|
||||
return _pretty_segments((f"(alive, {count} {unit})", None))
|
||||
|
||||
|
||||
def _pty_write_detail(_args_json: str, result: str) -> str:
|
||||
"""``write_pty`` / ``write_pty_keys`` / ``ask_into_pty``: size, sent, error.
|
||||
|
||||
The human's answer never appears here — only that it was written.
|
||||
"""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
fields = _status_fields(result)
|
||||
if fields.get("source") == "human":
|
||||
extra = ", secret" if fields.get("secret") == "true" else ""
|
||||
return _pretty_segments((f"(answer sent{extra})", "green"))
|
||||
wrote = fields.get("wrote", "")
|
||||
if wrote:
|
||||
unit = "char" if wrote == "1" else "chars"
|
||||
return _pretty_segments((f"({wrote} {unit})", None))
|
||||
return _pretty_segments(("(done)", "green"))
|
||||
|
||||
|
||||
def _close_pty_detail(_args_json: str, result: str) -> str:
|
||||
"""``close_pty`` outcome: closed, already closed, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
if _status_fields(result).get("closed") == "true":
|
||||
return _pretty_segments(("(closed)", "green"))
|
||||
return _pretty_segments(("(already closed)", "dim"))
|
||||
|
||||
|
||||
def _ask_line(verb: str, field: str, detail: Callable[[str, str], str]) -> PrettyLine:
|
||||
"""``* Ask <verb> '<subject>' `` plus the verb's own outcome.
|
||||
|
||||
*field* names the argument that carries the human-facing subject: the
|
||||
question for ``ask_user_line`` / ``ask_user_choice``, the title for a form.
|
||||
"""
|
||||
label = f"Ask {verb}" if verb else "Ask"
|
||||
|
||||
def prefix(args_json: str) -> str:
|
||||
subject = _json_str_arg(args_json, field) or "?"
|
||||
return _pretty_prefix(f"* {label} '{_preview(subject, _ASK_SUBJECT_PREVIEW)}' ")
|
||||
|
||||
return PrettyLine(prefix=prefix, detail=detail)
|
||||
|
||||
|
||||
def _ask_detail(_args_json: str, result: str) -> str:
|
||||
"""``ask_user_line`` outcome: an answer came back, or the prompt failed."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
return _pretty_segments(("(answered)" if result else "(empty)", "green"))
|
||||
|
||||
|
||||
def _ask_choice_detail(_args_json: str, result: str) -> str:
|
||||
"""``ask_user_choice`` outcome: the chosen label, or the prompt failed."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
if not result:
|
||||
return _pretty_segments(("(no selection)", "dim"))
|
||||
return _pretty_segments((f"({_preview(result, _ASK_SUBJECT_PREVIEW)})", "green"))
|
||||
|
||||
|
||||
def _ask_form_detail(_args_json: str, result: str) -> str:
|
||||
"""``ask_user_form`` outcome: answer count, or the prompt failed."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
try:
|
||||
loaded: object = json.loads(result)
|
||||
except json.JSONDecodeError:
|
||||
return _pretty_segments(("(answered)", "green"))
|
||||
if not isinstance(loaded, dict):
|
||||
return _pretty_segments(("(answered)", "green"))
|
||||
count = len(cast("dict[str, object]", loaded))
|
||||
unit = "answer" if count == 1 else "answers"
|
||||
return _pretty_segments((f"({count} {unit})", "green"))
|
||||
|
||||
|
||||
def _access_prefix(args_json: str) -> str:
|
||||
path = _json_str_arg(args_json, "path") or "?"
|
||||
mode = _json_str_arg(args_json, "mode") or "read"
|
||||
return _pretty_prefix(f"* Access '{path}' ({mode}) ")
|
||||
|
||||
|
||||
def _access_detail(_args_json: str, result: str) -> str:
|
||||
"""``request_directory_access``: granted, already allowed, or denied."""
|
||||
if result.startswith("access granted:"):
|
||||
return _pretty_segments(("(granted)", "green"))
|
||||
if result.startswith("already accessible:"):
|
||||
return _pretty_segments(("(already accessible)", "dim"))
|
||||
if result.startswith("already granted:"):
|
||||
return _pretty_segments(("(already granted)", "dim"))
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
return _pretty_segments(("(done)", None))
|
||||
|
||||
|
||||
def _temp_dir_prefix(_args_json: str) -> str:
|
||||
return _pretty_prefix("* Temp Dir ")
|
||||
|
||||
|
||||
def _temp_dir_detail(_args_json: str, result: str) -> str:
|
||||
"""``new_temporary_workspace`` outcome: the created path, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
path = _status_fields(result).get("temporary_workspace", "")
|
||||
return _pretty_segments((f"({path})" if path else "(done)", "dim"))
|
||||
|
||||
|
||||
def _resume_prefix(_args_json: str) -> str:
|
||||
return _pretty_prefix("* Resume Truncated ")
|
||||
|
||||
|
||||
def _resume_detail(_args_json: str, result: str) -> str:
|
||||
"""``get_truncated`` outcome: the resumed range, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
return _range_detail(result) or _pretty_segments(("(done)", "green"))
|
||||
|
||||
|
||||
def _vcs_kind_detail(_args_json: str, result: str) -> str:
|
||||
"""``vcs_kind`` outcome: the detected kind, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
return _pretty_segments((f"({result})", None))
|
||||
|
||||
|
||||
def _vcs_branch_detail(_args_json: str, result: str) -> str:
|
||||
"""``vcs_branch`` outcome: the current branch or head, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
return _pretty_segments((f"({result})", None))
|
||||
|
||||
|
||||
def _vcs_diff_detail(_args_json: str, result: str) -> str:
|
||||
"""``vcs_diff`` outcome: changed files and line counts, none, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
if result == "(no diff)":
|
||||
return _pretty_segments(("(no diff)", "dim"))
|
||||
files = 0
|
||||
added = 0
|
||||
removed = 0
|
||||
for line in result.splitlines():
|
||||
if line.startswith("diff --git "):
|
||||
files += 1
|
||||
elif line.startswith("+") and not line.startswith("+++"):
|
||||
added += 1
|
||||
elif line.startswith("-") and not line.startswith("---"):
|
||||
removed += 1
|
||||
file_unit = "file" if files == 1 else "files"
|
||||
return _pretty_segments((f"({files} {file_unit}, +{added}/-{removed})", None))
|
||||
|
||||
|
||||
def _mcp_detail(_args_json: str, result: str) -> str:
|
||||
"""MCP tool outcome: done, or the error the server returned."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
return _pretty_segments(("(done)", "green"))
|
||||
|
||||
|
||||
def _skill_list_prefix(_args_json: str) -> str:
|
||||
return _pretty_prefix("* Skills ")
|
||||
|
||||
|
||||
def _skill_list_detail(_args_json: str, result: str) -> str:
|
||||
"""``skill_list`` outcome: how many skills were found, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
match = re.match(r"skills: (\d+)", result)
|
||||
if match is None:
|
||||
return _pretty_segments(("(none)", "dim"))
|
||||
count = int(match.group(1))
|
||||
unit = "skill" if count == 1 else "skills"
|
||||
return _pretty_segments((f"({count} {unit})", None if count else "dim"))
|
||||
|
||||
|
||||
def _skill_read_prefix(args_json: str) -> str:
|
||||
name = _json_str_arg(args_json, "name") or "?"
|
||||
file = _json_str_arg(args_json, "file") or ""
|
||||
subject = f"'{name}'" if file in {"", _SKILL_MAIN_FILE} else f"'{name}' {file}"
|
||||
return _pretty_prefix(f"* Skill {subject} ")
|
||||
|
||||
|
||||
def _skill_read_detail(_args_json: str, result: str) -> str:
|
||||
"""``skill_read`` outcome: the range read, or the failure kind."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
return _range_detail(result) or _pretty_segments(("(done)", "green"))
|
||||
|
||||
|
||||
def _skill_search_prefix(args_json: str) -> str:
|
||||
pattern = _json_str_arg(args_json, "pattern") or "?"
|
||||
scope = _json_str_arg(args_json, "skill") or ""
|
||||
where = f" in '{scope}'" if scope else ""
|
||||
return _pretty_prefix(f"* Skill Search '{_preview(pattern, _ASK_SUBJECT_PREVIEW)}'{where} ")
|
||||
|
||||
|
||||
def _skill_search_detail(_args_json: str, result: str) -> str:
|
||||
"""``skill_search`` outcome: matches and skills, none, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
if result == "(no matches)":
|
||||
return _pretty_segments(("(no matches)", "dim"))
|
||||
if result == "(no skills to search)":
|
||||
return _pretty_segments(("(no skills)", "dim"))
|
||||
matches = [line for line in result.splitlines() if line and not line.startswith("...[truncated")]
|
||||
skills = {line.split("/", 1)[0] for line in matches}
|
||||
match_unit = "match" if len(matches) == 1 else "matches"
|
||||
skill_unit = "skill" if len(skills) == 1 else "skills"
|
||||
return _pretty_segments((f"({len(matches)} {match_unit} in {len(skills)} {skill_unit})", None))
|
||||
|
||||
|
||||
def _skill_create_prefix(args_json: str) -> str:
|
||||
name = _json_str_arg(args_json, "name") or "?"
|
||||
return _pretty_prefix(f"* Skill Create '{name}' ")
|
||||
|
||||
|
||||
def _skill_create_detail(_args_json: str, result: str) -> str:
|
||||
"""``skill_create`` outcome: created (with any notes), or the failure."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
detail = _pretty_segments(("(created)", "green"))
|
||||
notes = result.partition("\nnotes: ")[2]
|
||||
if notes:
|
||||
detail += _pretty_segments((f" — {notes}", "yellow"))
|
||||
return detail
|
||||
|
||||
|
||||
def _skill_edit_prefix(args_json: str) -> str:
|
||||
name = _json_str_arg(args_json, "name") or "?"
|
||||
file = _json_str_arg(args_json, "file") or ""
|
||||
subject = f"'{name}'" if file in {"", _SKILL_MAIN_FILE} else f"'{name}' {file}"
|
||||
return _pretty_prefix(f"* Skill Edit {subject} ")
|
||||
|
||||
|
||||
def _skill_edit_detail(_args_json: str, result: str) -> str:
|
||||
"""``skill_edit`` outcome: the change made, or the failure."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
first = result.splitlines()[0] if result else ""
|
||||
match = re.search(r"\((.+)\)$", first)
|
||||
text = match.group(1) if match is not None else "updated"
|
||||
detail = _pretty_segments((f"({text})", "green"))
|
||||
notes = result.partition("\nnotes: ")[2]
|
||||
if notes:
|
||||
detail += _pretty_segments((f" — {notes}", "yellow"))
|
||||
return detail
|
||||
|
||||
|
||||
# Pretty tools: ``prefix`` prints as the call starts, ``detail`` when it lands.
|
||||
_PRETTY_LINES: dict[str, PrettyLine] = {
|
||||
"read_file": PrettyLine(prefix=_read_file_prefix, detail=_read_file_detail),
|
||||
"tree": PrettyLine(prefix=_tree_prefix, detail=_tree_detail),
|
||||
"todo_list": _todo_line("todo_list"),
|
||||
"todo_push": _todo_line("todo_push"),
|
||||
"todo_pop": _todo_line("todo_pop"),
|
||||
"todo_update": _todo_line("todo_update"),
|
||||
"todo_clear": _todo_line("todo_clear"),
|
||||
"listdir": PrettyLine(prefix=_listdir_prefix, detail=_listdir_detail),
|
||||
"glob_paths": PrettyLine(prefix=_glob_prefix, detail=_glob_detail),
|
||||
"grep_files": PrettyLine(prefix=_grep_prefix, detail=_grep_detail),
|
||||
"regex_files": PrettyLine(prefix=_regex_prefix, detail=_regex_detail),
|
||||
"get_truncated": PrettyLine(prefix=_resume_prefix, detail=_resume_detail),
|
||||
"run_argv": PrettyLine(prefix=_run_argv_prefix, detail=_run_argv_detail),
|
||||
"run_argv_batch": PrettyLine(prefix=_run_argv_batch_prefix, detail=_run_argv_batch_detail),
|
||||
"fetch": PrettyLine(prefix=_fetch_prefix, detail=_fetch_detail),
|
||||
@@ -470,19 +813,50 @@ _PRETTY_LINES: dict[str, PrettyLine] = {
|
||||
"copy_path": _mutator_line("copy_path"),
|
||||
"move_path": _mutator_line("move_path"),
|
||||
"delete_path": _mutator_line("delete_path"),
|
||||
"new_temporary_workspace": PrettyLine(prefix=_temp_dir_prefix, detail=_temp_dir_detail),
|
||||
"request_directory_access": PrettyLine(prefix=_access_prefix, detail=_access_detail),
|
||||
"skill_list": PrettyLine(prefix=_skill_list_prefix, detail=_skill_list_detail),
|
||||
"skill_read": PrettyLine(prefix=_skill_read_prefix, detail=_skill_read_detail),
|
||||
"skill_search": PrettyLine(prefix=_skill_search_prefix, detail=_skill_search_detail),
|
||||
"skill_create": PrettyLine(prefix=_skill_create_prefix, detail=_skill_create_detail),
|
||||
"skill_edit": PrettyLine(prefix=_skill_edit_prefix, detail=_skill_edit_detail),
|
||||
"open_pty": PrettyLine(prefix=_open_pty_prefix, detail=_open_pty_detail),
|
||||
"read_pty": _pty_session_line("Read", _read_pty_detail),
|
||||
"write_pty": _pty_session_line("Write", _pty_write_detail),
|
||||
"write_pty_keys": _pty_session_line("Keys", _pty_write_detail),
|
||||
"ask_into_pty": _pty_session_line("Ask", _pty_write_detail),
|
||||
"close_pty": _pty_session_line("Close", _close_pty_detail),
|
||||
"ask_user_line": _ask_line("", "question", _ask_detail),
|
||||
"ask_user_choice": _ask_line("Choose", "question", _ask_choice_detail),
|
||||
"ask_user_form": _ask_line("Form", "title", _ask_form_detail),
|
||||
"vcs_kind": PrettyLine(prefix=_vcs_kind_prefix, detail=_vcs_kind_detail),
|
||||
"vcs_status": PrettyLine(prefix=_vcs_status_prefix, detail=_vcs_status_detail),
|
||||
"vcs_diff": PrettyLine(prefix=_vcs_diff_prefix, detail=_vcs_diff_detail),
|
||||
"vcs_log": PrettyLine(prefix=_vcs_log_prefix, detail=_vcs_log_detail),
|
||||
"vcs_branch": PrettyLine(prefix=_vcs_branch_prefix, detail=_vcs_branch_detail),
|
||||
"wait": PrettyLine(prefix=_wait_prefix, detail=_wait_detail),
|
||||
}
|
||||
|
||||
_PRETTY_TOOLS = frozenset(_PRETTY_LINES)
|
||||
|
||||
|
||||
def _pretty_parts(name: str, args_json: str, result: str) -> tuple[str, str] | None:
|
||||
"""Prefix + detail for a known tool call; None keeps the ``[tool]`` style."""
|
||||
line = _PRETTY_LINES.get(name)
|
||||
if line is None:
|
||||
def _mcp_line(name: str) -> PrettyLine | None:
|
||||
"""Generic renderer for namespaced MCP tools (``mcp__<server>__<tool>``)."""
|
||||
if not name.startswith(_MCP_TOOL_PREFIX):
|
||||
return None
|
||||
server, _, tool = name.removeprefix(_MCP_TOOL_PREFIX).partition("__")
|
||||
label = f"{server or '?'}:{tool or '?'}"
|
||||
return PrettyLine(
|
||||
prefix=lambda _args_json: _pretty_prefix(f"* MCP {label} "),
|
||||
detail=_mcp_detail,
|
||||
)
|
||||
|
||||
|
||||
def _pretty_line_for(name: str) -> PrettyLine | None:
|
||||
"""Resolve the renderer for *name*: a named entry, else the MCP naming rule."""
|
||||
return _PRETTY_LINES.get(name) or _mcp_line(name)
|
||||
|
||||
|
||||
def _pretty_parts(line: PrettyLine, args_json: str, result: str) -> tuple[str, str]:
|
||||
"""Prefix + detail for a resolved pretty tool call."""
|
||||
return line.prefix(args_json), line.detail(args_json, result)
|
||||
|
||||
|
||||
@@ -493,25 +867,58 @@ def _echo_stream(text: str) -> None:
|
||||
_ = sys.stdout.flush()
|
||||
|
||||
|
||||
def _clear_streamed_lines(line_count: int) -> None:
|
||||
"""Move cursor up and clear the streamed plain-text region (TTY only)."""
|
||||
def _clear_streamed_lines(line_count: int, *, resume_above: bool = False) -> None:
|
||||
"""Clear exactly *line_count* terminal lines ending at the cursor line.
|
||||
|
||||
The cursor ends at column 0 of the topmost cleared line, so a re-render
|
||||
overwrites the erased block in place instead of appending below it. With
|
||||
``resume_above`` the cursor steps one line further up: the next
|
||||
``"\\n"``-prefixed write then reuses the last cleared line — needed by the
|
||||
parallel-batch fallback, which must not touch the line above the prefix.
|
||||
|
||||
``line_count`` counts *physical* rows; callers fold wrapped lines via
|
||||
:func:`_line_count_for_clear`.
|
||||
"""
|
||||
if line_count <= 0:
|
||||
return
|
||||
# Clear current line, then each previous line of the streamed block.
|
||||
for _ in range(line_count):
|
||||
# Clear the cursor line, then each line above it while moving up.
|
||||
for _ in range(line_count - 1):
|
||||
_ = sys.stdout.write("\r\033[2K\033[1A")
|
||||
_ = sys.stdout.write("\r\033[2K")
|
||||
if resume_above:
|
||||
_ = sys.stdout.write("\033[1A")
|
||||
with contextlib.suppress(OSError):
|
||||
_ = sys.stdout.flush()
|
||||
|
||||
|
||||
def _terminal_columns() -> int:
|
||||
"""Terminal width for wrap-aware row counts (fallback: 80)."""
|
||||
try:
|
||||
columns = shutil.get_terminal_size().columns
|
||||
except OSError, ValueError:
|
||||
return 80
|
||||
return columns if columns > 0 else 80
|
||||
|
||||
|
||||
def _physical_line_count(text: str) -> int:
|
||||
"""Rows streamed *text* occupies on the terminal, counting wrapping.
|
||||
|
||||
``_echo_stream`` writes without a trailing newline, so the cursor sits at
|
||||
the end of the last row and owns one row even when the text ends in ``"\\n"``.
|
||||
A row filled to the exact width is counted once: the wrap of the final
|
||||
column is pending, so erasing must not claim the row below it.
|
||||
"""
|
||||
columns = _terminal_columns()
|
||||
return sum(max(1, -(-len(line) // columns)) for line in text.split("\n"))
|
||||
|
||||
|
||||
def _line_count_for_clear(label: str, body: str) -> int:
|
||||
"""Approximate terminal lines used by ``label\\n + body`` for cursor erase."""
|
||||
"""Physical terminal rows used by ``label\\n + body`` for cursor erase."""
|
||||
if not body and not label:
|
||||
return 0
|
||||
# Label is on its own line; body may contain newlines.
|
||||
# Label is on its own line; body may contain newlines and wrap.
|
||||
text = f"{label}\n{body}" if label else body
|
||||
return text.count("\n") + 1
|
||||
return _physical_line_count(text)
|
||||
|
||||
|
||||
class _PrettyToolStream:
|
||||
@@ -540,7 +947,9 @@ class _PrettyToolStream:
|
||||
return
|
||||
if self._open:
|
||||
# Parallel batch: the open prefix could never receive its detail.
|
||||
_clear_streamed_lines(1)
|
||||
# Erase only the prefix line — the line above may be streamed
|
||||
# reasoning — and let the fallback's leading newline reuse it.
|
||||
_clear_streamed_lines(1, resume_above=True)
|
||||
self._open = False
|
||||
self._whole_lines = True
|
||||
return
|
||||
@@ -589,7 +998,8 @@ def _flush_assistant_markdown(body: str, *, pretty: bool) -> None:
|
||||
click.echo()
|
||||
return
|
||||
if pretty:
|
||||
lines = _line_count_for_clear("assistant:", body)
|
||||
# +1: the blank separator ``begin_assistant`` printed above the label.
|
||||
lines = _line_count_for_clear("assistant:", body) + 1
|
||||
_clear_streamed_lines(lines)
|
||||
print_markdown(body, label="assistant:")
|
||||
click.echo()
|
||||
@@ -625,7 +1035,7 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
|
||||
printed_assistant = False
|
||||
# Tool calls buffer so prettified tools can render once their result lands
|
||||
# (the summary line needs the status/range). FIFO matches loop event order.
|
||||
pending_tools: list[tuple[str, str, bool]] = []
|
||||
pending_tools: list[tuple[str, str, PrettyLine | None]] = []
|
||||
|
||||
def flush_assistant() -> None:
|
||||
nonlocal source, assistant_buf, printed_assistant
|
||||
@@ -678,23 +1088,23 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
|
||||
if isinstance(call, AssistantFunctionToolCall):
|
||||
name = call.function.name
|
||||
args = call.function.arguments
|
||||
pretty_tool = name in _PRETTY_TOOLS
|
||||
pending_tools.append((name, args, pretty_tool))
|
||||
if pretty_tool:
|
||||
tool_lines.start_call(_PRETTY_LINES[name].prefix(args))
|
||||
line = _pretty_line_for(name)
|
||||
pending_tools.append((name, args, line))
|
||||
if line is not None:
|
||||
tool_lines.start_call(line.prefix(args))
|
||||
else:
|
||||
tool_lines.close_line()
|
||||
preview = _preview(args, _TOOL_ARGS_PREVIEW)
|
||||
click.secho(f"\n[tool] {name}({preview})", fg="yellow")
|
||||
else:
|
||||
tool_lines.close_line()
|
||||
pending_tools.append(("custom", call.id, False))
|
||||
pending_tools.append(("custom", call.id, None))
|
||||
click.secho(f"\n[tool] custom id={call.id}", fg="yellow")
|
||||
elif isinstance(event, ToolResultEvent):
|
||||
flush_assistant()
|
||||
content = event.message.content
|
||||
name, args, pretty_tool = pending_tools.pop(0) if pending_tools else ("", "", False)
|
||||
parts = _pretty_parts(name, args, content) if pretty_tool else None
|
||||
name, args, line = pending_tools.pop(0) if pending_tools else ("", "", None)
|
||||
parts = _pretty_parts(line, args, content) if line is not None else None
|
||||
if parts is not None and not show_full:
|
||||
prefix, detail = parts
|
||||
if not tool_lines.finish_call(detail):
|
||||
|
||||
+166
-52
@@ -4,7 +4,10 @@ import asyncio
|
||||
import contextlib
|
||||
import signal
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Generator
|
||||
@@ -12,21 +15,17 @@ if TYPE_CHECKING:
|
||||
|
||||
type SigHandler = Callable[[int, FrameType | None], None] | int | None
|
||||
|
||||
# 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]
|
||||
|
||||
class InterruptTarget(Protocol):
|
||||
"""Something a pending SIGINT can cancel (a task, a prompt read, a hint)."""
|
||||
|
||||
def allow_task_cancel() -> bool:
|
||||
"""Whether the CLI SIGINT handler should cancel the in-flight turn task."""
|
||||
return _prompt_depth == 0
|
||||
def cancel(self) -> object:
|
||||
"""Cancel the pending work; the return value is ignored.
|
||||
|
||||
|
||||
def set_sigint_reinstall(callback: Callable[[], None] | None) -> None:
|
||||
"""Register how to re-bind SIGINT after a TTY prompt (set by run_cancellable)."""
|
||||
_reinstall_holder[0] = callback
|
||||
Declared loosely (``object``) so ``asyncio.Task.cancel`` — which returns
|
||||
``bool`` and takes an optional message — satisfies it.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def raise_keyboard_interrupt_sigint(signum: int, frame: FrameType | None) -> None:
|
||||
@@ -56,57 +55,172 @@ def install_keyboard_interrupt_sigint() -> None:
|
||||
_ = signal.signal(signal.SIGINT, raise_keyboard_interrupt_sigint)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SIGINT routing (session scope)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _RouterState:
|
||||
"""Session SIGINT routing state (one instance, kept in ``_router``)."""
|
||||
|
||||
# Cancel targets, innermost last: a SIGINT cancels the innermost one.
|
||||
targets: list[InterruptTarget] = field(default_factory=list)
|
||||
installed: bool = False
|
||||
# Nesting depth of main-thread blocking reads. A plain counter (not a
|
||||
# ContextVar): asyncio.add_signal_handler freezes the ContextVar snapshot at
|
||||
# install time, so a ContextVar reset after reinstall would leave SIGINT
|
||||
# permanently routed to the wrong place.
|
||||
blocking_reads: int = 0
|
||||
|
||||
|
||||
_router = _RouterState()
|
||||
|
||||
|
||||
def sigint_routed() -> bool:
|
||||
"""Whether the session SIGINT router currently owns SIGINT."""
|
||||
return _router.installed
|
||||
|
||||
|
||||
def install_sigint_router() -> bool:
|
||||
"""Install the session SIGINT handler; False when the loop cannot take it.
|
||||
|
||||
A SIGINT delivered while the event loop is parked in its selector is raised
|
||||
in the *loop's* frame: it escapes ``run_until_complete``, the Runner cancels
|
||||
the main task and the REPL (or a half-finished shutdown) is left broken —
|
||||
the next Ctrl+C then kills the process. Keeps an asyncio-level SIGINT
|
||||
handler installed for the whole session instead: it cancels the innermost
|
||||
target registered with :func:`sigint_cancels` and never raises.
|
||||
|
||||
Blocking main-thread reads suspend the router via
|
||||
:func:`pause_task_cancel_for_prompt`. Platforms without asyncio signal
|
||||
handlers (Windows proactor) return False and keep the plain
|
||||
KeyboardInterrupt behaviour. Idempotent.
|
||||
"""
|
||||
if _router.installed:
|
||||
return True
|
||||
if not _add_router_handler():
|
||||
return False
|
||||
_router.installed = True
|
||||
return True
|
||||
|
||||
|
||||
def uninstall_sigint_router() -> None:
|
||||
"""Drop the session SIGINT handler and restore the KeyboardInterrupt one."""
|
||||
if not _router.installed:
|
||||
return
|
||||
_router.installed = False
|
||||
_remove_router_handler()
|
||||
_router.targets.clear()
|
||||
install_keyboard_interrupt_sigint()
|
||||
|
||||
|
||||
def _add_router_handler() -> bool:
|
||||
try:
|
||||
asyncio.get_running_loop().add_signal_handler(signal.SIGINT, _route_sigint)
|
||||
except RuntimeError, NotImplementedError, ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _remove_router_handler() -> None:
|
||||
with contextlib.suppress(NotImplementedError, RuntimeError, ValueError):
|
||||
_ = asyncio.get_running_loop().remove_signal_handler(signal.SIGINT)
|
||||
|
||||
|
||||
def _route_sigint() -> None:
|
||||
"""Cancel the innermost target (none → ignore: never escape the loop)."""
|
||||
if _router.targets:
|
||||
_ = _router.targets[-1].cancel()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def sigint_cancels(target: InterruptTarget) -> Generator[None]:
|
||||
"""Route SIGINT to ``target.cancel()`` while inside (innermost wins)."""
|
||||
_router.targets.append(target)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for index in range(len(_router.targets) - 1, -1, -1):
|
||||
if _router.targets[index] is target:
|
||||
del _router.targets[index]
|
||||
break
|
||||
|
||||
|
||||
@contextmanager
|
||||
def pause_task_cancel_for_prompt() -> Generator[None]:
|
||||
"""Disable turn-task cancel during blocking TTY prompts (confirm, etc.).
|
||||
"""Give SIGINT to a blocking *main-thread* read instead of routing it.
|
||||
|
||||
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.
|
||||
The read (REPL entry prompt, synchronous click confirm/prompt) runs on the
|
||||
main thread inside a coroutine, so the resulting KeyboardInterrupt is raised
|
||||
in the reader's own frame: its handlers see it, and neither the in-flight
|
||||
turn nor the event loop is cancelled. Use it only around blocking reads — an
|
||||
``await`` that parks the loop in its selector must keep the router installed
|
||||
(:func:`install_sigint_router`).
|
||||
|
||||
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``.
|
||||
Suspends the router; nested calls restore it when the outermost exits.
|
||||
"""
|
||||
|
||||
global _prompt_depth # noqa: PLW0603
|
||||
_prompt_depth += 1
|
||||
loop_handler_removed = False
|
||||
_router.blocking_reads += 1
|
||||
resuming = _router.blocking_reads == 1 and _router.installed
|
||||
if resuming:
|
||||
_remove_router_handler()
|
||||
previous: SigHandler = signal.SIG_DFL
|
||||
try:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
_ = loop.remove_signal_handler(signal.SIGINT)
|
||||
loop_handler_removed = True
|
||||
except RuntimeError, NotImplementedError, ValueError:
|
||||
loop_handler_removed = False
|
||||
|
||||
try:
|
||||
previous = signal.getsignal(signal.SIGINT)
|
||||
_ = signal.signal(signal.SIGINT, signal.default_int_handler)
|
||||
except ValueError:
|
||||
# Not on the main thread — skip OS signal rebinding.
|
||||
previous = signal.SIG_DFL
|
||||
previous = signal.getsignal(signal.SIGINT)
|
||||
_ = signal.signal(signal.SIGINT, raise_keyboard_interrupt_sigint)
|
||||
except ValueError:
|
||||
# Not on the main thread — signal handlers cannot be rebound here.
|
||||
previous = signal.SIG_DFL
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
with contextlib.suppress(ValueError):
|
||||
_ = signal.signal(signal.SIGINT, previous)
|
||||
_prompt_depth = max(0, _prompt_depth - 1)
|
||||
# Reinstall only when fully out of prompts and cancel is allowed again.
|
||||
# Order matters: decrement depth first so allow_task_cancel() is True
|
||||
# before reinstall (and handlers never freeze allow=False).
|
||||
if _prompt_depth == 0 and loop_handler_removed:
|
||||
reinstall = _reinstall_holder[0]
|
||||
if reinstall is not None:
|
||||
with contextlib.suppress(RuntimeError, NotImplementedError, ValueError):
|
||||
reinstall()
|
||||
_router.blocking_reads = max(0, _router.blocking_reads - 1)
|
||||
if resuming:
|
||||
_ = _add_router_handler()
|
||||
|
||||
|
||||
async def run_in_prompt_thread[**P, R](func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
"""Run a blocking TTY prompt off the event loop with cancel paused.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CancelHook:
|
||||
"""SIGINT target for a poll-based prompt read (abort that read)."""
|
||||
|
||||
Pause/SIGINT rebinding happens on the main thread; only the prompt body
|
||||
runs in a worker so the asyncio loop stays free and turn cancel is disabled.
|
||||
cancel_read: Callable[[], None]
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancel_read()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _WaitingPrompt:
|
||||
"""SIGINT target for a prompt read that cannot be interrupted.
|
||||
|
||||
A readline read blocks inside the C library on a worker thread; aborting it
|
||||
would abandon that thread on stdin, where it would eat the next line the
|
||||
human types. Explaining itself once is the best available answer.
|
||||
"""
|
||||
with pause_task_cancel_for_prompt():
|
||||
return await asyncio.to_thread(func, *args, **kwargs)
|
||||
|
||||
_hinted: bool = False
|
||||
|
||||
def cancel(self) -> None:
|
||||
if self._hinted:
|
||||
return
|
||||
self._hinted = True
|
||||
click.secho(
|
||||
"Ctrl+C ignored while a prompt is waiting — answer it to continue",
|
||||
fg="yellow",
|
||||
err=True,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def off_loop_prompt(cancel: Callable[[], None] | None = None) -> Generator[None]:
|
||||
"""A human prompt is waiting in a worker thread.
|
||||
|
||||
While inside, SIGINT must not cancel the enclosing turn: poll-based reads
|
||||
(``cancel`` hook) abort just that read, readline-based ones ignore it with a
|
||||
one-line hint (see :class:`_WaitingPrompt`).
|
||||
"""
|
||||
target: InterruptTarget = _WaitingPrompt() if cancel is None else _CancelHook(cancel)
|
||||
with sigint_cancels(target):
|
||||
yield
|
||||
|
||||
@@ -7,7 +7,7 @@ import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from plyngent.cli.interrupt import pause_task_cancel_for_prompt
|
||||
from plyngent.cli.interrupt import off_loop_prompt, pause_task_cancel_for_prompt
|
||||
from plyngent.prompting import (
|
||||
ChoiceOption,
|
||||
NonInteractiveError,
|
||||
@@ -21,6 +21,7 @@ from plyngent.prompting import (
|
||||
)
|
||||
from plyngent.tools.access import AccessDecision
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
from plyngent.tools.skills import SkillWriteDecision
|
||||
from plyngent.tools.workspace import AccessMode, parse_access_mode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -347,6 +348,54 @@ def prompt_policy_fetch_confirm(
|
||||
return False
|
||||
|
||||
|
||||
def prompt_skill_write_confirm(
|
||||
skill_dir: Path,
|
||||
action: str,
|
||||
detail: str,
|
||||
timeout_seconds: float,
|
||||
) -> SkillWriteDecision:
|
||||
"""Ask whether to write into *skill_dir* (timed; default deny).
|
||||
|
||||
``o``/``y`` allows this write; ``s`` allows this skill directory for the rest
|
||||
of the chat; anything else (or a timeout, cancel, or non-interactive stdin)
|
||||
denies. Independent of YOLO: a skill directory is not a path grant, so the
|
||||
human is asked even with ``--yes`` unless ``[skills].allow_write`` is set.
|
||||
"""
|
||||
lines = [
|
||||
f"action: {action} skill {skill_dir.name!r}",
|
||||
f"path: {skill_dir}",
|
||||
f"change: {detail}",
|
||||
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 SkillWriteDecision(allow=False)
|
||||
backend.echo()
|
||||
backend.secho(format_tool_confirm_box(f"skills {action}", "\n".join(lines)), fg="yellow")
|
||||
backend.echo()
|
||||
prompt = (
|
||||
f"[skills] allow {action} in {skill_dir.name!r}? [o]nce/[s]ession/[n]o (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"[skills] timed out after {timeout_seconds:g}s — denied", fg="red", err=True)
|
||||
return SkillWriteDecision(allow=False)
|
||||
token = raw.strip().lower()
|
||||
if token in {"s", "session"}:
|
||||
return SkillWriteDecision(allow=True, session=True)
|
||||
if token in {"o", "once", "y", "yes"}:
|
||||
return SkillWriteDecision(allow=True)
|
||||
return SkillWriteDecision(allow=False)
|
||||
except NonInteractiveError, KeyboardInterrupt, EOFError:
|
||||
return SkillWriteDecision(allow=False)
|
||||
|
||||
|
||||
def prompt_directory_access_confirm(
|
||||
path: Path,
|
||||
mode: AccessMode,
|
||||
@@ -521,10 +570,14 @@ async def prompt_workspace_mismatch_async(
|
||||
|
||||
|
||||
def install_cli_limit_hooks() -> None:
|
||||
"""Register interactive continue hooks and prompt cancel-pause.
|
||||
"""Register interactive continue hooks and prompt guards.
|
||||
|
||||
Command denylist policy confirm is set on :class:`~plyngent.tools.context.InstanceState`
|
||||
when the REPL starts (no process-global hook).
|
||||
Off-loop prompts (worker-thread reads) register their own SIGINT target so a
|
||||
Ctrl+C there never cancels the enclosing turn; synchronous prompts read on
|
||||
the main thread and take SIGINT as a KeyboardInterrupt instead. Command
|
||||
denylist policy confirm is set on
|
||||
:class:`~plyngent.tools.context.InstanceState` when the REPL starts (no
|
||||
process-global hook).
|
||||
"""
|
||||
configure_prompting(pause_factory=pause_task_cancel_for_prompt)
|
||||
configure_prompting(pause_factory=off_loop_prompt)
|
||||
PtyManager.set_limit_continue_hook(prompt_continue_limit)
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
|
||||
import click
|
||||
|
||||
from plyngent.cli.input_text import read_repl_entry
|
||||
from plyngent.cli.interrupt import pause_task_cancel_for_prompt
|
||||
from plyngent.cli.readline_setup import setup_readline
|
||||
from plyngent.cli.retry import run_user_text_with_retries
|
||||
from plyngent.cli.slash import handle_slash
|
||||
@@ -25,7 +26,7 @@ def _echo_user(text: str) -> None:
|
||||
|
||||
def _echo_interrupted() -> None:
|
||||
# A further Ctrl+C during this output must stay benign (same rule as
|
||||
# retry._echo_cancel_lines): after a turn the SIGINT handler is removed, so
|
||||
# retry._echo_cancel_lines): on platforms without asyncio signal handlers
|
||||
# the next Ctrl+C raises KeyboardInterrupt at an arbitrary bytecode.
|
||||
with contextlib.suppress(KeyboardInterrupt):
|
||||
click.echo()
|
||||
@@ -51,16 +52,22 @@ async def run_repl(state: ReplState) -> None:
|
||||
click.echo('Type /help for commands. Multiline: """ … """. Empty line is ignored.')
|
||||
|
||||
while True:
|
||||
# The prompt read blocks the main thread inside this coroutine, so hand
|
||||
# SIGINT to it: Ctrl+C raises KeyboardInterrupt here (caught below and
|
||||
# inside read_repl_entry) and re-prompts. Everywhere else the session
|
||||
# SIGINT router owns the signal (turn cancel, prompt cancel).
|
||||
try:
|
||||
entry = read_repl_entry()
|
||||
with pause_task_cancel_for_prompt():
|
||||
entry = read_repl_entry()
|
||||
except EOFError:
|
||||
click.echo()
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
# Stray interrupt outside the read (e.g. right after the router was
|
||||
# suspended): re-prompt rather than leave the REPL.
|
||||
_echo_interrupted()
|
||||
continue
|
||||
|
||||
# Between turns the turn-task SIGINT handler is removed (run_cancellable),
|
||||
# so a stray Ctrl+C raises KeyboardInterrupt at whatever bytecode is
|
||||
# executing — echoing user text, expire_yolo_once, gaps between
|
||||
# statements. Swallow it and re-prompt instead of exiting the REPL.
|
||||
try:
|
||||
if entry is None:
|
||||
continue
|
||||
|
||||
+27
-49
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import signal
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -10,7 +9,7 @@ import click
|
||||
|
||||
from plyngent.agent import AssistantMessageEvent
|
||||
from plyngent.cli.display import render_events
|
||||
from plyngent.cli.interrupt import allow_task_cancel, set_sigint_reinstall
|
||||
from plyngent.cli.interrupt import install_sigint_router, sigint_cancels
|
||||
from plyngent.cli.limits import reset_auto_continue_turn
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -47,7 +46,9 @@ async def sleep_cancellable(seconds: float) -> bool:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return True
|
||||
await asyncio.sleep(min(0.5, remaining))
|
||||
sleeper = asyncio.ensure_future(asyncio.sleep(min(0.5, remaining)))
|
||||
with sigint_cancels(sleeper):
|
||||
await sleeper
|
||||
except asyncio.CancelledError:
|
||||
return False
|
||||
except KeyboardInterrupt:
|
||||
@@ -57,56 +58,32 @@ async def sleep_cancellable(seconds: float) -> bool:
|
||||
async def run_cancellable[T](coro: Coroutine[object, object, T]) -> T:
|
||||
"""Await ``coro`` as a task; Ctrl+C / SIGINT cancels the task.
|
||||
|
||||
Interactive prompts (max-rounds / destructive confirm) temporarily disable
|
||||
task cancel so the user can answer instead of aborting the whole turn.
|
||||
Interactive prompts (max-rounds / destructive confirm) register their own
|
||||
SIGINT target for as long as they wait, so the turn is not cancelled while
|
||||
the human is answering (see :func:`~plyngent.cli.interrupt.off_loop_prompt`).
|
||||
|
||||
Raises:
|
||||
asyncio.CancelledError: If the task was cancelled (including via SIGINT).
|
||||
"""
|
||||
task: asyncio.Task[T] = asyncio.create_task(coro)
|
||||
loop = asyncio.get_running_loop()
|
||||
installed = False
|
||||
# What to restore once the turn-cancel handler is removed. remove_signal_handler
|
||||
# leaves SIG_DFL, which would terminate the process on a stray Ctrl+C between
|
||||
# turns; restore the handler from before (the CLI installs a
|
||||
# KeyboardInterrupt-raising one so the REPL can catch it).
|
||||
previous_sigint = signal.getsignal(signal.SIGINT)
|
||||
|
||||
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()
|
||||
|
||||
def _reinstall() -> None:
|
||||
try:
|
||||
loop.add_signal_handler(signal.SIGINT, _on_sigint)
|
||||
except NotImplementedError, RuntimeError, ValueError:
|
||||
return
|
||||
|
||||
# Keep a loop-level SIGINT handler in place for the rest of the session, so
|
||||
# a Ctrl+C that lands while the loop is parked in its selector cancels this
|
||||
# task instead of being raised in the loop's own frame.
|
||||
_ = install_sigint_router()
|
||||
try:
|
||||
loop.add_signal_handler(signal.SIGINT, _on_sigint)
|
||||
installed = True
|
||||
set_sigint_reinstall(_reinstall)
|
||||
except NotImplementedError, RuntimeError, ValueError:
|
||||
installed = False
|
||||
|
||||
try:
|
||||
return await task
|
||||
except KeyboardInterrupt:
|
||||
if not task.done():
|
||||
_ = task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
raise asyncio.CancelledError from None
|
||||
with sigint_cancels(task):
|
||||
try:
|
||||
return await task
|
||||
except KeyboardInterrupt:
|
||||
# Platforms without asyncio signal handlers (Windows proactor)
|
||||
# keep the CLI's KeyboardInterrupt handler, so the interrupt
|
||||
# surfaces here while the task is still awaiting.
|
||||
if not task.done():
|
||||
_ = task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
raise asyncio.CancelledError from None
|
||||
finally:
|
||||
set_sigint_reinstall(None)
|
||||
if installed:
|
||||
with contextlib.suppress(NotImplementedError, RuntimeError, ValueError):
|
||||
_ = loop.remove_signal_handler(signal.SIGINT)
|
||||
with contextlib.suppress(ValueError):
|
||||
_ = signal.signal(signal.SIGINT, previous_sigint)
|
||||
# Only cancel if still running (e.g. KeyboardInterrupt path above).
|
||||
# Do not cancel a finished task — that would mask success.
|
||||
if not task.done():
|
||||
@@ -143,9 +120,10 @@ def _echo_turn_usage(agent: ChatAgent) -> None:
|
||||
def _echo_cancel_lines(*lines: str) -> None:
|
||||
"""Print post-cancel lines, treating a further Ctrl+C as benign.
|
||||
|
||||
After a turn-cancel the asyncio SIGINT handler is removed, so a second
|
||||
Ctrl+C during this output would otherwise raise KeyboardInterrupt and
|
||||
exit the REPL instead of returning to the prompt.
|
||||
The session SIGINT router keeps the loop safe between turns, but platforms
|
||||
without asyncio signal handlers (Windows proactor) deliver a second Ctrl+C
|
||||
here as a KeyboardInterrupt, which must not exit the REPL instead of
|
||||
returning to the prompt.
|
||||
"""
|
||||
try:
|
||||
for line in lines:
|
||||
|
||||
+148
-161
@@ -2,23 +2,15 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
from typing import TYPE_CHECKING, Any, cast, override
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast, override
|
||||
|
||||
import awaitlet # pyright: ignore[reportMissingTypeStubs]
|
||||
import click
|
||||
from click.shell_completion import CompletionItem
|
||||
from msgspec import UNSET
|
||||
|
||||
from plyngent.cli.models_source import DEFAULT_MODELS_CACHE_TTL, model_choices_for_provider
|
||||
from plyngent.cli.retry import retry_pending_with_retries
|
||||
from plyngent.cli.selection import select_model, select_provider
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantChatMessage,
|
||||
AssistantFunctionToolCall,
|
||||
DeveloperChatMessage,
|
||||
SystemChatMessage,
|
||||
UserChatMessage,
|
||||
)
|
||||
from plyngent.runtime import ProviderNotSupportedError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -26,10 +18,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from plyngent.agent import ToolRegistry
|
||||
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")
|
||||
@@ -49,33 +38,77 @@ _TODO_ACTION_CHOICES = (
|
||||
_ROUNDS_CHOICES = ("8", "16", "32", "64", "128")
|
||||
|
||||
|
||||
class HistoryLimitType(click.ParamType[int]):
|
||||
"""``N`` (int >= 1) or the shortcut ``last`` (equivalent to ``1``)."""
|
||||
class HistoryTargetType(click.ParamType[int | Literal["last"]]):
|
||||
"""``N`` (turn number >= 1) or the keyword ``last`` (most recent turn)."""
|
||||
|
||||
name: str = "history_limit"
|
||||
name: str = "history_target"
|
||||
|
||||
@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()
|
||||
def convert(
|
||||
self,
|
||||
value: object,
|
||||
param: click.Parameter | None,
|
||||
ctx: click.Context | None,
|
||||
) -> int | Literal["last"]:
|
||||
text = value.strip().lower() if isinstance(value, str) else str(value)
|
||||
if text == "last":
|
||||
return 1
|
||||
return "last"
|
||||
try:
|
||||
n = int(text, 10)
|
||||
except ValueError:
|
||||
self.fail("expected an integer N or 'last'", param, ctx)
|
||||
self.fail("expected a turn number 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())]
|
||||
del param
|
||||
tokens = ["last"]
|
||||
state = _repl_state(ctx)
|
||||
if state is not None:
|
||||
from plyngent.cli.transcript import build_transcript
|
||||
|
||||
turns = build_transcript(state.agent.messages).turns
|
||||
# Recent turn numbers are the ones a human is likely to jump back to.
|
||||
tokens += [str(turn.ordinal) for turn in turns[-5:]]
|
||||
token = incomplete.strip().lower()
|
||||
return [CompletionItem(item) for item in tokens if item.startswith(token)]
|
||||
|
||||
|
||||
HISTORY_TARGET = HistoryTargetType()
|
||||
|
||||
|
||||
class HistoryRowNumberType(click.ParamType[int]):
|
||||
"""One-based display number of a transcript row (``/history --message N``)."""
|
||||
|
||||
name: str = "history_message"
|
||||
|
||||
@override
|
||||
def convert(self, value: object, param: click.Parameter | None, ctx: click.Context | None) -> int:
|
||||
try:
|
||||
n = int(str(value), 10)
|
||||
except ValueError:
|
||||
self.fail("expected a message number N", 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 param
|
||||
state = _repl_state(ctx)
|
||||
if state is None:
|
||||
return []
|
||||
from plyngent.cli.transcript import build_transcript
|
||||
|
||||
transcript = build_transcript(state.agent.messages)
|
||||
token = incomplete.strip()
|
||||
recent = range(max(1, transcript.messages - 4), transcript.messages + 1)
|
||||
return [CompletionItem(str(n)) for n in recent if str(n).startswith(token)]
|
||||
|
||||
|
||||
HISTORY_ROW_NUMBER = HistoryRowNumberType()
|
||||
|
||||
|
||||
HELP_FOOTER = (
|
||||
@@ -557,6 +590,7 @@ def status_cmd(state: ReplState) -> None:
|
||||
f"rounds={last_rounds}\n"
|
||||
f"usage_session={session_u.format_line(billed=True)}\n"
|
||||
f"grants={len(access_grant_rows(instance=state.instance_state, session=state.session_state))} "
|
||||
f"skills={len(state.skills.skills) if state.skills is not None else 'off'} "
|
||||
f"workspace={state.workspace}"
|
||||
)
|
||||
|
||||
@@ -1217,6 +1251,54 @@ def mcp_slash_cmd(state: ReplState, action: str | None) -> None:
|
||||
_print_mcp_status(state)
|
||||
|
||||
|
||||
@slash.command("skills")
|
||||
@click.argument("action", required=False, type=click.Choice(["list", "search", "read", "reload"]))
|
||||
@click.argument("target", required=False)
|
||||
@click.option("--file", "file", default="", help="File inside the skill (with ``read``).")
|
||||
@click.pass_obj
|
||||
def skills_slash_cmd(state: ReplState, action: str | None, target: str | None, file: str) -> None:
|
||||
"""List skills, search or read them, or rescan the roots.
|
||||
|
||||
``/skills`` or ``/skills list`` — discovered skills with their source root.
|
||||
``/skills search PATTERN`` — regex over every skill's contents.
|
||||
``/skills read NAME [--file F]`` — print a skill's SKILL.md (or one file).
|
||||
``/skills reload`` — rescan the roots after an external edit.
|
||||
"""
|
||||
from plyngent.tools.context import bind_tool_context
|
||||
from plyngent.tools.skills import SKILL_TOOL_NAMES, format_skill_table, skill_read, skill_search
|
||||
|
||||
if state.skills is None:
|
||||
click.echo("skills: disabled (set [skills].enabled = true)")
|
||||
return
|
||||
act = (action or "list").lower()
|
||||
if act == "reload":
|
||||
state.reload_skills()
|
||||
_slash_rebuild_tools_if_on(state)
|
||||
click.echo(f"skills reloaded: {len(state.skills.skills)} visible; tools rebuilt")
|
||||
return
|
||||
# The tool handlers read the bound instance (the registry binds it for a
|
||||
# model call; a slash command has to do it itself).
|
||||
with bind_tool_context(instance=state.instance_state, session=state.session_state):
|
||||
if act == "search":
|
||||
if not target:
|
||||
msg = "/skills search requires a pattern"
|
||||
raise click.UsageError(msg)
|
||||
click.echo(_await(skill_search.handler(target)))
|
||||
return
|
||||
if act == "read":
|
||||
if not target:
|
||||
msg = "/skills read requires a skill name"
|
||||
raise click.UsageError(msg)
|
||||
click.echo(_await(skill_read.handler(target, file or "SKILL.md")))
|
||||
return
|
||||
click.echo(format_skill_table(state.skills))
|
||||
if not state.tools_enabled:
|
||||
click.secho(
|
||||
f"note: tools are off, so {', '.join(sorted(SKILL_TOOL_NAMES))} are not offered to the model",
|
||||
fg="bright_black",
|
||||
)
|
||||
|
||||
|
||||
@slash.command("grants")
|
||||
@click.argument("action", required=False, type=click.Choice(["list", "revoke"]))
|
||||
@click.argument("target", required=False)
|
||||
@@ -1367,65 +1449,62 @@ def rounds_cmd(state: ReplState, n: int | None) -> None:
|
||||
|
||||
|
||||
@slash.command("history")
|
||||
@click.argument("n", required=False, type=HistoryLimitType())
|
||||
@click.argument("target", required=False, type=HISTORY_TARGET)
|
||||
@click.argument("count", required=False, type=click.IntRange(min=1), metavar="[N]")
|
||||
@click.option(
|
||||
"--full",
|
||||
"mode_full",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Print full message bodies (markdown for assistant when TTY).",
|
||||
"--message",
|
||||
"message_no",
|
||||
type=HISTORY_ROW_NUMBER,
|
||||
default=None,
|
||||
metavar="N",
|
||||
help="Print message N (display number) instead of a turn.",
|
||||
)
|
||||
@click.option(
|
||||
"-v",
|
||||
"--verbose",
|
||||
"verbose",
|
||||
count=True,
|
||||
help="Print every row of the turn(s); repeat (-vv) for full bodies.",
|
||||
)
|
||||
@click.option(
|
||||
"--preview",
|
||||
"mode_preview",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Force short previews even for a single message.",
|
||||
help="One-line preview for every printed row.",
|
||||
)
|
||||
@click.pass_obj
|
||||
def history_cmd(
|
||||
state: ReplState,
|
||||
n: int | None,
|
||||
target: int | Literal["last"] | None,
|
||||
count: int | None,
|
||||
*,
|
||||
mode_full: bool,
|
||||
message_no: int | None,
|
||||
verbose: int,
|
||||
mode_preview: bool,
|
||||
) -> None:
|
||||
"""Show recent messages (default: last 20 as short previews).
|
||||
"""Show conversation history, grouped by turn.
|
||||
|
||||
``/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.
|
||||
``/history`` — the last turn: your message and the model's final answer
|
||||
``/history N`` — turn N (numbered from the first turn of this session)
|
||||
``/history last [N]`` — the last N turns (default 1)
|
||||
``/history --message N`` — one message (display number, not a DB row)
|
||||
The turn's ends are printed in full; ``-v`` adds every intermediate row (tool
|
||||
calls/results, notices) as a one-liner, ``-vv`` prints those in full too, and
|
||||
``--preview`` collapses every row (ends included) to one line.
|
||||
"""
|
||||
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)
|
||||
slice_msgs = messages[start:]
|
||||
# Full: --full, or single-message window (last / 1) unless --preview.
|
||||
full = mode_full or (len(slice_msgs) == 1 and not mode_preview)
|
||||
mode_tag = "full" if full else "preview"
|
||||
click.echo(f"session={state.session_id} messages={len(messages)} showing={len(slice_msgs)} mode={mode_tag}")
|
||||
for offset, message in enumerate(slice_msgs):
|
||||
idx = start + offset
|
||||
if full:
|
||||
_print_history_message_full(idx, message)
|
||||
else:
|
||||
click.echo(_format_history_message(idx, message))
|
||||
if state.agent.pending_retry_text is not None:
|
||||
pending = state.agent.pending_retry_text
|
||||
if full:
|
||||
click.secho("(pending retry) user:", fg="yellow")
|
||||
click.echo(pending)
|
||||
else:
|
||||
click.secho(
|
||||
f"(pending retry) user: {_preview_content(pending)}",
|
||||
fg="yellow",
|
||||
)
|
||||
from plyngent.cli import transcript as transcript_mod
|
||||
|
||||
transcript_mod.echo_history(
|
||||
state.agent.messages,
|
||||
session_id=state.session_id,
|
||||
pending_retry_text=state.agent.pending_retry_text,
|
||||
target=target,
|
||||
count=count,
|
||||
message_no=message_no,
|
||||
verbose=verbose,
|
||||
preview=mode_preview,
|
||||
)
|
||||
|
||||
|
||||
@slash.command("todos")
|
||||
@@ -1519,98 +1598,6 @@ def retry_cmd(state: ReplState) -> None:
|
||||
_ = _await(retry_pending_with_retries(state.agent))
|
||||
|
||||
|
||||
def _preview_content(text: str | None) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
if len(text) <= _CONTENT_PREVIEW:
|
||||
return text
|
||||
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:
|
||||
parts.append(_preview_content(message.content))
|
||||
tool_calls = message.tool_calls
|
||||
if tool_calls is not UNSET and tool_calls:
|
||||
names: list[str] = []
|
||||
for call in tool_calls:
|
||||
if isinstance(call, AssistantFunctionToolCall):
|
||||
names.append(call.function.name)
|
||||
else:
|
||||
names.append("custom")
|
||||
parts.append(f"tool_calls=[{', '.join(names)}]")
|
||||
body = " ".join(parts) if parts else "(empty)"
|
||||
return f"{index}. assistant: {body}"
|
||||
# ToolChatMessage
|
||||
return f"{index}. tool({message.tool_call_id}): {_preview_content(message.content)}"
|
||||
|
||||
|
||||
def _run_slash_argv(args: Sequence[str], state: ReplState) -> None:
|
||||
"""Sync Click entrypoint; may call awaitlet() for async work."""
|
||||
# standalone_mode=False → UsageError/ClickException instead of SystemExit.
|
||||
|
||||
@@ -35,6 +35,7 @@ if TYPE_CHECKING:
|
||||
from plyngent.memory import MemoryStore
|
||||
from plyngent.memory.database.schema import Session as SessionRow
|
||||
from plyngent.runtime.mcp_client import McpManager
|
||||
from plyngent.skills import SkillStore
|
||||
from plyngent.tools.access import AccessDecision
|
||||
from plyngent.tools.workspace import AccessMode
|
||||
|
||||
@@ -88,6 +89,8 @@ class ReplState:
|
||||
session_state: SessionState = field(default_factory=SessionState)
|
||||
# MCP connections started by the host app before this state was built.
|
||||
mcp_manager: McpManager | None = None
|
||||
# Discovered skills (None when ``[skills].enabled`` is false).
|
||||
skills: SkillStore | None = field(default=None, init=False, repr=False)
|
||||
_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)
|
||||
@@ -103,6 +106,7 @@ class ReplState:
|
||||
self.instance_state.workspace_root = self.workspace
|
||||
self.instance_state.workspace.root = self.workspace
|
||||
self._install_config_access()
|
||||
self._install_skills()
|
||||
from plyngent.cli.limits import set_auto_continue_default
|
||||
|
||||
set_auto_continue_default(enabled=self.auto_continue_limits)
|
||||
@@ -302,6 +306,71 @@ class ReplState:
|
||||
err=True,
|
||||
)
|
||||
|
||||
def _install_skills(self) -> None:
|
||||
"""Build the skill store and expose its roots read-only to path tools.
|
||||
|
||||
Roots follow ``[skills]`` (explicit paths first, then our own user
|
||||
directory and other harnesses' directories, with the project-scope ones
|
||||
tied to the current workspace). Writing is *not* part of this: the roots
|
||||
are read-only, and skill writes go through the write policy installed
|
||||
below, so a skill directory never becomes a general write grant.
|
||||
"""
|
||||
from plyngent.cli.limits import prompt_skill_write_confirm
|
||||
from plyngent.config.path import get_default_path
|
||||
from plyngent.skills import SkillStore, build_roots
|
||||
from plyngent.tools.skills import SkillWritePolicy, set_skill_write_policy
|
||||
from plyngent.tools.workspace import set_static_read_roots
|
||||
|
||||
cfg = self.config.skills_config
|
||||
set_skill_write_policy(
|
||||
SkillWritePolicy(allow_all=cfg.allow_write, confirm=prompt_skill_write_confirm),
|
||||
instance=self.instance_state,
|
||||
)
|
||||
if not cfg.enabled:
|
||||
self.skills = None
|
||||
self.instance_state.skills = None
|
||||
_ = set_static_read_roots([], instance=self.instance_state)
|
||||
return
|
||||
roots = build_roots(
|
||||
cfg,
|
||||
workspace=self.workspace,
|
||||
user_config_dir=get_default_path().parent,
|
||||
home=Path.home(),
|
||||
)
|
||||
self.skills = SkillStore(roots)
|
||||
self.instance_state.skills = self.skills
|
||||
_ = set_static_read_roots([root.path for root in roots], instance=self.instance_state)
|
||||
|
||||
def reload_skills(self) -> None:
|
||||
"""Rescan the roots (after an external edit or a config change)."""
|
||||
if self.skills is not None:
|
||||
self.skills.reload()
|
||||
|
||||
def _skills_text(self) -> str:
|
||||
"""Fold the discovered skills into the system prompt (like MCP instructions).
|
||||
|
||||
Only names, sources, and descriptions: the bodies stay on disk so a turn
|
||||
pays for the skill it actually reads. Notes and shadowed copies are left
|
||||
to ``skill_list``, which the model can call when something looks off.
|
||||
"""
|
||||
cfg = self.config.skills_config
|
||||
if self.skills is None or not self.tools_enabled or not cfg.inject_catalog:
|
||||
return ""
|
||||
skills = self.skills.skills
|
||||
if not skills:
|
||||
return ""
|
||||
limit = max(0, cfg.max_catalog_skills)
|
||||
shown = skills[:limit]
|
||||
lines = [
|
||||
"### Skills",
|
||||
"Available skills (read a skill's SKILL.md with `skill_read` before following it):",
|
||||
]
|
||||
lines.extend(f"- {skill.name} ({skill.root.label}): {skill.description}" for skill in shown)
|
||||
if len(skills) > len(shown):
|
||||
lines.append(f"- … and {len(skills) - len(shown)} more; use `skill_search` or `skill_list`")
|
||||
lines.append("Search all skills with `skill_search`; create or update one with `skill_create` / `skill_edit`.")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _tool_registry(self) -> ToolRegistry | None:
|
||||
if not self.tools_enabled:
|
||||
return None
|
||||
@@ -309,6 +378,7 @@ class ReplState:
|
||||
from plyngent.tools.catalog import register_builtin_tools
|
||||
from plyngent.tools.danger import classify_danger
|
||||
from plyngent.tools.plugins import load_plugin_tools
|
||||
from plyngent.tools.skills import SKILL_TOOL_NAMES
|
||||
|
||||
plugins_cfg = self.config.plugins_config
|
||||
catalog = register_builtin_tools()
|
||||
@@ -323,7 +393,9 @@ class ReplState:
|
||||
# Connections were started by the host; registration is sync-only.
|
||||
_ = register_mcp_tools(self.mcp_manager, self.config.mcp_config)
|
||||
# Local surface: builtins + allowlisted plugins (import registers into catalog).
|
||||
tools = catalog.select(surface="local")
|
||||
# ``[skills].enabled = false`` drops the skill tools from the surface.
|
||||
excluded = None if self.config.skills_config.enabled else set(SKILL_TOOL_NAMES)
|
||||
tools = catalog.select(surface="local", exclude_names=excluded)
|
||||
yolo = self.effective_yolo() != "off"
|
||||
# Always attach soft-confirm path so non-YOLO tools still prompt under YOLO mode.
|
||||
return ToolRegistry(
|
||||
@@ -357,6 +429,7 @@ class ReplState:
|
||||
system_prompt = compose_agent_system_content(
|
||||
agent_cfg.system_prompt,
|
||||
agent_cfg.tool_directives,
|
||||
self._skills_text(),
|
||||
mcp_text,
|
||||
)
|
||||
on_limit = prompt_continue_limit_async if (self.interactive_limits or auto_continue_enabled()) else None
|
||||
@@ -539,6 +612,8 @@ class ReplState:
|
||||
|
||||
self.config.reload()
|
||||
self.instance_state.workspace.path_denylist = tuple(self.config.agent_config.path_denylist or ())
|
||||
# Roots and their switches came from the file that was just re-read.
|
||||
self._install_skills()
|
||||
|
||||
selectable = self.config.selectable_providers()
|
||||
preferred_provider = self.provider_name if self.provider_name in selectable else None
|
||||
@@ -576,6 +651,8 @@ class ReplState:
|
||||
self.workspace = resolved
|
||||
self.instance_state.workspace_root = resolved
|
||||
self.instance_state.workspace.root = resolved
|
||||
# Project-scope skill roots follow the workspace.
|
||||
self._install_skills()
|
||||
if hasattr(self, "agent") and self.agent.tools is not None:
|
||||
self.agent.tools.set_instance_state(self.instance_state)
|
||||
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
"""Turn-oriented transcript grouping/rendering for ``/history``.
|
||||
|
||||
The REPL transcript is a flat list of chat messages, but what a human reviews is
|
||||
a *turn*: their message, the tool-loop rounds it triggered, and the model's final
|
||||
answer. This module groups the list into numbered rows and turns, and renders
|
||||
either the two ends of a turn (default), every row of it (``-v``), or full bodies
|
||||
for every printed row (``-vv``).
|
||||
|
||||
Numbering is a **display ordinal** over the conversation rows (1-based, in the
|
||||
order the session holds them): it is what ``/history`` prints and what
|
||||
``/history --message N`` addresses. Database row numbers are deliberately never
|
||||
shown or used — they are an implementation detail that shifts with ``/clear``.
|
||||
Local-only rows (the injected system prompt) have no number.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import click
|
||||
from msgspec import UNSET
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantChatMessage,
|
||||
AssistantFunctionToolCall,
|
||||
DeveloperChatMessage,
|
||||
SystemChatMessage,
|
||||
ToolChatMessage,
|
||||
UserChatMessage,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||
|
||||
PREVIEW_CHARS = 200
|
||||
# ``-v`` expands a turn's rows; ``-vv`` also prints their full bodies.
|
||||
MAX_VERBOSE = 2
|
||||
|
||||
# Detail policy: ``mixed`` = a turn's two ends (the user message and the final
|
||||
# answer) in full, the rounds between them on one line; ``full``/``preview``
|
||||
# force the extremes (``--preview`` also collapses the ends).
|
||||
type Detail = Literal["mixed", "full", "preview"]
|
||||
# ``/history N`` addresses a turn by number; ``last`` counts from the end.
|
||||
type TurnTarget = int | Literal["last"]
|
||||
|
||||
|
||||
def _content(message: AnyChatMessage) -> str:
|
||||
"""Message body as text (``content`` may be UNSET/None for assistants)."""
|
||||
return message.content if isinstance(message.content, str) else ""
|
||||
|
||||
|
||||
def one_line(text: str | None, *, limit: int = PREVIEW_CHARS) -> str:
|
||||
"""Collapse *text* to one line: first line, truncated, may add ``(+N lines)``."""
|
||||
body = (text or "").strip()
|
||||
if not body:
|
||||
return ""
|
||||
lines = body.splitlines()
|
||||
head = lines[0].strip()
|
||||
if len(head) > limit:
|
||||
head = head[:limit] + "…"
|
||||
if len(lines) > 1:
|
||||
head += f" (+{len(lines) - 1} lines)"
|
||||
return head
|
||||
|
||||
|
||||
def _role(message: AnyChatMessage) -> str:
|
||||
if isinstance(message, UserChatMessage):
|
||||
return "user"
|
||||
if isinstance(message, AssistantChatMessage):
|
||||
return "assistant"
|
||||
if isinstance(message, DeveloperChatMessage):
|
||||
return "developer"
|
||||
if isinstance(message, SystemChatMessage):
|
||||
return "system"
|
||||
assert isinstance(message, ToolChatMessage)
|
||||
return f"tool({message.tool_call_id})"
|
||||
|
||||
|
||||
_ROLE_COLOR: dict[str, str] = {
|
||||
"user": "green",
|
||||
"assistant": "cyan",
|
||||
"developer": "blue",
|
||||
"system": "bright_black",
|
||||
}
|
||||
|
||||
|
||||
def _role_color(message: AnyChatMessage) -> str:
|
||||
return "magenta" if isinstance(message, ToolChatMessage) else _ROLE_COLOR[_role(message)]
|
||||
|
||||
|
||||
def _tool_call_names(message: AssistantChatMessage) -> list[str]:
|
||||
tool_calls = message.tool_calls
|
||||
if tool_calls is UNSET or not tool_calls:
|
||||
return []
|
||||
return [call.function.name if isinstance(call, AssistantFunctionToolCall) else "custom" for call in tool_calls]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Row:
|
||||
"""One transcript row; ``number`` is the display ordinal (None = local-only)."""
|
||||
|
||||
message: AnyChatMessage
|
||||
number: int | None
|
||||
|
||||
def prefix(self) -> str:
|
||||
"""``12. user:`` — always styled (local rows keep the role colour)."""
|
||||
label = "local" if self.number is None else str(self.number)
|
||||
return str(click.style(f"{label}. {_role(self.message)}:", fg=_role_color(self.message)))
|
||||
|
||||
def preview(self) -> str:
|
||||
message = self.message
|
||||
names = _tool_call_names(message) if isinstance(message, AssistantChatMessage) else []
|
||||
suffix = f" tool_calls=[{', '.join(names)}]" if names else ""
|
||||
text = one_line(_content(message))
|
||||
if not text and isinstance(message, AssistantChatMessage) and not names:
|
||||
text = "(empty)"
|
||||
return f"{text}{suffix}".strip()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Turn:
|
||||
"""A user turn: the user row plus the rounds it triggered."""
|
||||
|
||||
ordinal: int
|
||||
rows: tuple[Row, ...]
|
||||
incomplete: bool = False
|
||||
|
||||
@property
|
||||
def user(self) -> Row:
|
||||
return self.rows[0]
|
||||
|
||||
@property
|
||||
def last(self) -> Row:
|
||||
return self.rows[-1]
|
||||
|
||||
@property
|
||||
def rounds(self) -> int:
|
||||
"""Model rounds that asked for tools (one tool batch each)."""
|
||||
return sum(
|
||||
1
|
||||
for row in self.rows
|
||||
if isinstance(row.message, AssistantChatMessage) and bool(_tool_call_names(row.message))
|
||||
)
|
||||
|
||||
def label(self) -> str:
|
||||
numbers = [row.number for row in self.rows if row.number is not None]
|
||||
parts = [f"msgs {numbers[0]}-{numbers[-1]}" if numbers else "local rows"]
|
||||
if self.rounds:
|
||||
parts.append(f"rounds={self.rounds}")
|
||||
text = f"turn {self.ordinal} ({', '.join(parts)})"
|
||||
if self.incomplete:
|
||||
text += " [incomplete — /retry continues]"
|
||||
return text
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Transcript:
|
||||
"""Numbered rows grouped by turn (plus rows that precede the first turn)."""
|
||||
|
||||
prelude: tuple[Row, ...]
|
||||
turns: tuple[Turn, ...]
|
||||
|
||||
@property
|
||||
def messages(self) -> int:
|
||||
"""Numbered rows (what ``/history --message N`` can address)."""
|
||||
return sum(1 for turn in self.turns for row in turn.rows if row.number is not None) + sum(
|
||||
1 for row in self.prelude if row.number is not None
|
||||
)
|
||||
|
||||
def turn(self, ordinal: int) -> Turn | None:
|
||||
return next((turn for turn in self.turns if turn.ordinal == ordinal), None)
|
||||
|
||||
def row(self, number: int) -> Row | None:
|
||||
for row in self.prelude:
|
||||
if row.number == number:
|
||||
return row
|
||||
for turn in self.turns:
|
||||
for row in turn.rows:
|
||||
if row.number == number:
|
||||
return row
|
||||
return None
|
||||
|
||||
def turn_of(self, number: int) -> Turn | None:
|
||||
return next((turn for turn in self.turns if any(row.number == number for row in turn.rows)), None)
|
||||
|
||||
|
||||
def build_transcript(messages: Sequence[AnyChatMessage], *, pending_retry_text: str | None = None) -> Transcript:
|
||||
"""Group *messages* into numbered rows and turns.
|
||||
|
||||
A turn starts at a user message and runs until the next one, so tool results,
|
||||
developer notices/checkpoints and synthetic todo nags stay inside the turn
|
||||
they belong to. Rows before the first user message (the injected system
|
||||
prompt, a compact seed summary, a resume notice) form the prelude.
|
||||
|
||||
``pending_retry_text`` marks the last turn as incomplete when it matches that
|
||||
turn's user message (``ChatAgent.pending_retry_text``).
|
||||
"""
|
||||
rows: list[Row] = []
|
||||
number = 0
|
||||
for message in messages:
|
||||
if isinstance(message, SystemChatMessage):
|
||||
# The system prompt is injected locally and never persisted.
|
||||
rows.append(Row(message=message, number=None))
|
||||
continue
|
||||
number += 1
|
||||
rows.append(Row(message=message, number=number))
|
||||
|
||||
prelude: list[Row] = []
|
||||
turns: list[Turn] = []
|
||||
current: list[Row] = []
|
||||
for row in rows:
|
||||
if isinstance(row.message, UserChatMessage):
|
||||
if current:
|
||||
turns.append(Turn(ordinal=len(turns) + 1, rows=tuple(current)))
|
||||
current = [row]
|
||||
elif current:
|
||||
current.append(row)
|
||||
else:
|
||||
prelude.append(row)
|
||||
if current:
|
||||
turns.append(Turn(ordinal=len(turns) + 1, rows=tuple(current)))
|
||||
|
||||
if turns and pending_retry_text is not None:
|
||||
user = turns[-1].user.message
|
||||
if isinstance(user, UserChatMessage) and user.content == pending_retry_text:
|
||||
turns[-1] = replace(turns[-1], incomplete=True)
|
||||
return Transcript(prelude=tuple(prelude), turns=tuple(turns))
|
||||
|
||||
|
||||
def echo_row(row: Row, *, detail: Detail) -> None:
|
||||
"""Print one row: a one-line preview, or the full body for ``full``/``mixed``."""
|
||||
if detail == "preview":
|
||||
click.echo(f"{row.prefix()} {row.preview()}")
|
||||
return
|
||||
if isinstance(row.message, AssistantChatMessage):
|
||||
_echo_assistant(row, reasoning=detail == "full")
|
||||
return
|
||||
click.echo(row.prefix())
|
||||
click.echo(row.message.content or "")
|
||||
click.echo()
|
||||
|
||||
|
||||
def _echo_reasoning(message: AssistantChatMessage) -> None:
|
||||
"""Print the model's reasoning trace for *message*, when it has one."""
|
||||
shown = message.reasoning_content
|
||||
if not isinstance(shown, str) or not shown.strip():
|
||||
return
|
||||
click.secho("reasoning:", fg="bright_black")
|
||||
click.echo(shown)
|
||||
click.echo()
|
||||
|
||||
|
||||
def _echo_assistant(row: Row, *, reasoning: bool) -> None:
|
||||
"""Print an assistant row: the thinking first, then the answer it produced.
|
||||
|
||||
A transcript reads in the order the model produced it, and the live stream
|
||||
prints the reasoning before the answer; printing it after the body made the
|
||||
answer look like it had come before the thinking behind it.
|
||||
"""
|
||||
from plyngent.cli.display import markdown_render_available, print_markdown
|
||||
|
||||
message = row.message
|
||||
assert isinstance(message, AssistantChatMessage)
|
||||
click.echo(row.prefix())
|
||||
if reasoning:
|
||||
_echo_reasoning(message)
|
||||
content = message.content
|
||||
if isinstance(content, str) and content.strip():
|
||||
if markdown_render_available():
|
||||
print_markdown(content, label="")
|
||||
else:
|
||||
click.echo(content)
|
||||
names = _tool_call_names(message)
|
||||
if names:
|
||||
click.secho(f" tool_calls=[{', '.join(names)}]", fg="yellow")
|
||||
if not names and not _content(message).strip():
|
||||
click.echo("(empty)")
|
||||
click.echo()
|
||||
|
||||
|
||||
def echo_turn(turn: Turn, *, verbose: bool, detail: Detail) -> None:
|
||||
"""Print one turn: its header, then every row (verbose) or just its ends."""
|
||||
click.secho(turn.label(), fg="yellow" if turn.incomplete else "bright_black")
|
||||
if verbose:
|
||||
rows = turn.rows
|
||||
elif turn.last is turn.user:
|
||||
rows = (turn.user,)
|
||||
else:
|
||||
rows = (turn.user, turn.last)
|
||||
for row in rows:
|
||||
echo_row(row, detail=_row_detail(row, turn=turn, detail=detail))
|
||||
|
||||
|
||||
def _row_detail(row: Row, *, turn: Turn, detail: Detail) -> Detail:
|
||||
"""Effective detail for *row*.
|
||||
|
||||
Under ``mixed`` the two ends of a turn — the human's message and the model's
|
||||
answer — are the rows a reader actually wants, so they are never collapsed;
|
||||
the rounds between them stay one-liners.
|
||||
"""
|
||||
if detail != "mixed":
|
||||
return detail
|
||||
if row is turn.user:
|
||||
return "full"
|
||||
return "full" if row is turn.last and isinstance(row.message, AssistantChatMessage) else "preview"
|
||||
|
||||
|
||||
def echo_prelude_rows(rows: Sequence[Row], *, detail: Detail) -> None:
|
||||
"""Print rows that precede the first turn (memory seed, restart notice, …).
|
||||
|
||||
Local-only rows (the injected system prompt) are control-plane noise, so they
|
||||
only appear in a full dump (``-vv``) — numbered head rows always do.
|
||||
"""
|
||||
for row in rows:
|
||||
if row.number is None and detail != "full":
|
||||
continue
|
||||
echo_row(row, detail="preview" if detail == "mixed" else detail)
|
||||
|
||||
|
||||
def _resolve_detail(*, verbose: int, preview: bool) -> Detail:
|
||||
"""Map ``-v`` / ``-vv`` / ``--preview`` onto the detail policy."""
|
||||
if verbose > MAX_VERBOSE:
|
||||
msg = "use at most -vv"
|
||||
raise click.UsageError(msg)
|
||||
if verbose == MAX_VERBOSE and preview:
|
||||
msg = "use only one of -vv / --preview"
|
||||
raise click.UsageError(msg)
|
||||
if verbose == MAX_VERBOSE:
|
||||
return "full"
|
||||
return "preview" if preview else "mixed"
|
||||
|
||||
|
||||
def _echo_message(view: Transcript, head: str, number: int, *, detail: Detail) -> None:
|
||||
"""Print one addressed message in full (``--message``)."""
|
||||
row = view.row(number)
|
||||
if row is None:
|
||||
click.echo(f"error: no message {number} (messages 1-{view.messages})")
|
||||
return
|
||||
body: Detail = "preview" if detail == "preview" else "full"
|
||||
click.echo(f"{head} showing=message {number} mode={body}")
|
||||
echo_row(row, detail=body)
|
||||
owner = view.turn_of(number)
|
||||
if owner is not None:
|
||||
click.secho(f"(turn {owner.ordinal}; /history {owner.ordinal} -v to see it whole)", fg="bright_black")
|
||||
|
||||
|
||||
def _select_turns(
|
||||
view: Transcript,
|
||||
*,
|
||||
target: TurnTarget | None,
|
||||
count: int | None,
|
||||
) -> tuple[tuple[Turn, ...], str] | None:
|
||||
"""Resolve ``/history [N]`` / ``/history last [N]`` into turns to print."""
|
||||
if target is None or target == "last":
|
||||
want = count if count is not None else 1
|
||||
selected = view.turns[-want:]
|
||||
return selected, f"last {len(selected)} turn(s)"
|
||||
turn = view.turn(target)
|
||||
if turn is None:
|
||||
click.echo(f"error: no turn {target} (turns 1-{len(view.turns)})")
|
||||
return None
|
||||
return (turn,), f"turn {target}"
|
||||
|
||||
|
||||
def echo_history(
|
||||
messages: Sequence[AnyChatMessage],
|
||||
*,
|
||||
session_id: int | None,
|
||||
pending_retry_text: str | None = None,
|
||||
target: TurnTarget | None = None,
|
||||
count: int | None = None,
|
||||
message_no: int | None = None,
|
||||
verbose: int = 0,
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""Render ``/history``: a turn window, or one addressed message.
|
||||
|
||||
``target`` is a turn number or ``"last"``; ``count`` (only with ``"last"``)
|
||||
is how many turns; ``message_no`` prints a single row instead. Numbering is
|
||||
the display ordinal of this transcript, never a database row number.
|
||||
"""
|
||||
if message_no is not None and (target is not None or count is not None):
|
||||
msg = "use --message N alone"
|
||||
raise click.UsageError(msg)
|
||||
if message_no is not None and verbose:
|
||||
msg = "-v/-vv apply to turns: /history N"
|
||||
raise click.UsageError(msg)
|
||||
if count is not None and target != "last":
|
||||
msg = "a count applies to 'last': /history last [N]"
|
||||
raise click.UsageError(msg)
|
||||
detail = _resolve_detail(verbose=verbose, preview=preview)
|
||||
if not messages:
|
||||
click.echo("(no messages in this session)")
|
||||
return
|
||||
view = build_transcript(messages, pending_retry_text=pending_retry_text)
|
||||
head = f"session={session_id} turns={len(view.turns)} messages={view.messages}"
|
||||
|
||||
if message_no is not None:
|
||||
_echo_message(view, head, message_no, detail=detail)
|
||||
return
|
||||
selection = _select_turns(view, target=target, count=count)
|
||||
if selection is None:
|
||||
return
|
||||
selected, showing = selection
|
||||
click.echo(f"{head} showing={showing} mode={detail}")
|
||||
if verbose >= 1 and view.prelude:
|
||||
echo_prelude_rows(view.prelude, detail=detail)
|
||||
if not selected:
|
||||
click.echo("(no turns yet in this session)")
|
||||
for turn in selected:
|
||||
echo_turn(turn, verbose=verbose >= 1, detail=detail)
|
||||
@@ -21,6 +21,8 @@ from .models import OpenAICompatibleProvider as OpenAICompatibleProvider
|
||||
from .models import OpenAIProvider as OpenAIProvider
|
||||
from .models import PluginsConfig as PluginsConfig
|
||||
from .models import ProviderConfig as ProviderConfig
|
||||
from .models import SkillDiscoverySource as SkillDiscoverySource
|
||||
from .models import SkillsConfig as SkillsConfig
|
||||
from .models import compose_agent_system_content as compose_agent_system_content
|
||||
from .path import get_default_path as _get_default_path
|
||||
from .routing import EffectiveProvider as EffectiveProvider
|
||||
|
||||
@@ -13,6 +13,10 @@ type DeepSeekConvention = Literal["", "openai", "anthropic", "responses"]
|
||||
# Static directory pre-allow mode for ``[agent].allow_paths`` (path → mode).
|
||||
type AllowPathMode = Literal["read", "write", "exec"]
|
||||
|
||||
# Built-in skill discovery sources (``[skills].discover``): our own user config
|
||||
# directory, and the skill directories other harnesses install into.
|
||||
type SkillDiscoverySource = Literal["plyngent", "claude"]
|
||||
|
||||
# Built-in persona when ``[agent].system_prompt`` is omitted.
|
||||
# Set ``system_prompt = ""`` to omit the persona block only.
|
||||
# Override with a multi-line TOML literal (prefer ''' so nested " is fine).
|
||||
@@ -24,7 +28,7 @@ You are a professional coding agent in a workspace-bound tool environment.
|
||||
# Set ``tool_directives = ""`` to omit this block only.
|
||||
DEFAULT_TOOL_DIRECTIVES = """\
|
||||
### Workspace
|
||||
- Explore with `tree`/`listdir`/`glob_paths`/`grep_files`/`read_file` when unsure.
|
||||
- Explore with `tree`/`listdir`/`glob_paths`/`regex_files`/`read_file` when unsure.
|
||||
- Stay under the workspace (or a path from `new_temporary_workspace`). Respect path denylists.
|
||||
- Need a path outside the workspace? Call `request_directory_access` \
|
||||
(human approves; denylists apply) instead of retrying an escaped path.
|
||||
@@ -39,11 +43,20 @@ re-read with line numbers after any `edit_*` / `write_file` / copy/move/delete,
|
||||
since line numbers go stale.
|
||||
- `read_file` results start with a 1-based line range `L{begin}-{end}` (`offset` \
|
||||
is 0-based); `with_lineno` shows per-line numbers instead.
|
||||
- `regex_files` takes a Python `re` regex for `pattern`, not a filename glob \
|
||||
(`*.py` / `**/*.py` fail with `invalid regex`), and a literal file/dir `path` \
|
||||
(no glob expansion, so `src/**/*.py` fails with `path does not exist`); there is \
|
||||
no file-type filter, so narrow with `path` and filter hits yourself.
|
||||
- Truncated results (any tool) carry a `truncate_token=...`; use `get_truncated` \
|
||||
with it to keep reading (chains through truncations).
|
||||
|
||||
### Commands
|
||||
- Prefer `run_argv` / `run_argv_batch` (argv lists, no shell) over `bash -c` or similar.
|
||||
- A shell or interpreter (`bash` / `python` / `node` / …) always requires the user to confirm — \
|
||||
a `-c` one-liner, a script (`python x.py`), or a bare interactive shell alike — and each \
|
||||
confirm blocks the turn, so it costs time. Invoke the target command directly (argv) instead \
|
||||
of wrapping it in `bash -c` / `python -c` / `node`, and reach for a dedicated tool \
|
||||
(`read_file` / `edit_*` / `vcs_*` / `fetch` / …) when one already does the job.
|
||||
- Several `run_argv` calls in one step may run in parallel; use `run_argv_batch` \
|
||||
for ordered pipelines (`pipe_out` / `mix_stderr` as needed).
|
||||
- Prefer `vcs_*` for status/diff/log/branch when enough.
|
||||
@@ -206,6 +219,34 @@ class NetworkingConfig(Struct, omit_defaults=True):
|
||||
ssrf_assume_public_cidrs: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class SkillsConfig(Struct, omit_defaults=True):
|
||||
"""Skill directories the agent may discover, read, and (with a grant) edit.
|
||||
|
||||
A skill is a directory holding a ``SKILL.md`` (YAML frontmatter with at least
|
||||
``name`` / ``description``, then a Markdown body) plus optional bundled files
|
||||
(scripts, references, templates). Roots are scanned in this order:
|
||||
|
||||
1. ``paths`` — explicit roots, highest priority
|
||||
2. ``discover`` sources in order: ``plyngent`` = our user config dir
|
||||
(``<user config>/skills``), ``claude`` = Claude Code's skill dirs
|
||||
(``~/.claude/skills``; plus ``<workspace>/.claude/skills`` when
|
||||
``include_project_roots``)
|
||||
|
||||
A skill found in several roots shadows the lower-priority copies: they stay
|
||||
listed, never hidden. ``enabled = false`` registers no skill tools at all and
|
||||
injects no catalog. Writing needs an explicit grant (a confirm per call, or
|
||||
``allow_write`` for a standing one); reads need none.
|
||||
"""
|
||||
|
||||
enabled: bool = True
|
||||
paths: list[str] = field(default_factory=list)
|
||||
discover: list[SkillDiscoverySource] = field(default_factory=lambda: ["plyngent", "claude"])
|
||||
include_project_roots: bool = True
|
||||
inject_catalog: bool = True
|
||||
max_catalog_skills: int = 50
|
||||
allow_write: bool = False
|
||||
|
||||
|
||||
class ModelConfig(Struct, omit_defaults=True):
|
||||
"""Capability flags and optional routing overrides for a model.
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from .models import (
|
||||
NetworkingConfig,
|
||||
PluginsConfig,
|
||||
Provider,
|
||||
SkillsConfig,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -57,6 +58,14 @@ def _parse_networking(raw: dict[str, object]) -> NetworkingConfig:
|
||||
return NetworkingConfig()
|
||||
|
||||
|
||||
def _parse_skills(raw: dict[str, object]) -> SkillsConfig:
|
||||
"""Parse the ``[skills]`` section, falling back to defaults."""
|
||||
try:
|
||||
return msgspec.convert(raw, SkillsConfig)
|
||||
except msgspec.ValidationError:
|
||||
return SkillsConfig()
|
||||
|
||||
|
||||
def _parse_mcp(raw: dict[str, object]) -> McpConfig:
|
||||
"""Parse the ``[mcp]`` section, falling back to defaults.
|
||||
|
||||
@@ -150,6 +159,7 @@ class ConfigStore:
|
||||
_plugins: PluginsConfig
|
||||
_networking: NetworkingConfig
|
||||
_mcp: McpConfig
|
||||
_skills: SkillsConfig
|
||||
_providers: dict[str, Provider]
|
||||
_bad_providers: dict[str, object]
|
||||
_recoverable_providers: dict[str, Provider]
|
||||
@@ -163,6 +173,7 @@ class ConfigStore:
|
||||
self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {})))
|
||||
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {})))
|
||||
self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {})))
|
||||
self._skills = _parse_skills(cast("dict[str, object]", raw.get("skills", {})))
|
||||
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document)
|
||||
|
||||
@property
|
||||
@@ -220,6 +231,13 @@ class ConfigStore:
|
||||
"""Typed MCP section (server definitions + disable list)."""
|
||||
return self._mcp
|
||||
|
||||
# -- skills (read-only) --
|
||||
|
||||
@property
|
||||
def skills_config(self) -> SkillsConfig:
|
||||
"""Typed skills section (roots, discovery sources, catalog limits)."""
|
||||
return self._skills
|
||||
|
||||
def set_plugins_enable(self, names: Sequence[str]) -> PluginsConfig:
|
||||
"""Replace the plugin enable list (in-memory; call :meth:`write` to persist)."""
|
||||
cleaned = [name.strip() for name in names if name.strip()]
|
||||
|
||||
+79
-11
@@ -4,6 +4,7 @@ import asyncio
|
||||
import contextlib
|
||||
import select
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from contextlib import AbstractContextManager
|
||||
from dataclasses import dataclass
|
||||
@@ -19,6 +20,30 @@ class NonInteractiveError(RuntimeError):
|
||||
"""Raised when a prompt is required but no interactive backend is available."""
|
||||
|
||||
|
||||
class PromptCancelledError(RuntimeError):
|
||||
"""Raised by a poll-based prompt read when the human cancels it (Ctrl+C).
|
||||
|
||||
Only poll-based reads (``read_line_with_timeout``) can observe the cancel:
|
||||
they check :func:`prompt_cancelled` between polls, so the reader thread
|
||||
returns and nothing is left blocked on stdin.
|
||||
"""
|
||||
|
||||
|
||||
# Set by the SIGINT target registered for a poll-based prompt read (see
|
||||
# cli.interrupt.off_loop_prompt); cleared around each off-loop prompt.
|
||||
_prompt_cancel = threading.Event()
|
||||
|
||||
|
||||
def prompt_cancelled() -> bool:
|
||||
"""Whether the in-flight poll-based prompt read was cancelled by SIGINT."""
|
||||
return _prompt_cancel.is_set()
|
||||
|
||||
|
||||
def cancel_prompt_read() -> None:
|
||||
"""Ask the in-flight poll-based prompt read to return (thread-safe)."""
|
||||
_prompt_cancel.set()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ChoiceOption:
|
||||
"""One selectable option for :func:`choose`."""
|
||||
@@ -117,6 +142,8 @@ def _read_line_with_timeout_posix(
|
||||
|
||||
Returns the line (without trailing newline), ``""`` for an empty Enter, or
|
||||
``None`` when the timeout elapses before any line is complete (EOF too).
|
||||
Raises :class:`PromptCancelledError` when :func:`prompt_cancelled` turns true
|
||||
while waiting (the poll loop notices within one interval).
|
||||
"""
|
||||
_ = sys.stdout.write(prompt)
|
||||
_ = sys.stdout.flush()
|
||||
@@ -125,6 +152,8 @@ def _read_line_with_timeout_posix(
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
if prompt_cancelled():
|
||||
raise PromptCancelledError
|
||||
ready, _, _ = poll([stream], [], [], min(remaining, 0.1))
|
||||
if not ready:
|
||||
continue
|
||||
@@ -143,6 +172,8 @@ def _read_line_with_timeout_windows(prompt: str, timeout: float) -> str | None:
|
||||
deadline = time.monotonic() + timeout
|
||||
buf: list[str] = []
|
||||
while time.monotonic() < deadline:
|
||||
if prompt_cancelled():
|
||||
raise PromptCancelledError
|
||||
if msvcrt.kbhit():
|
||||
ch = msvcrt.getwch()
|
||||
if ch in ("\r", "\n"):
|
||||
@@ -171,7 +202,9 @@ def read_line_with_timeout(prompt: str, timeout: float) -> str | None:
|
||||
|
||||
Returns the typed line (without trailing newline), ``""`` for an empty
|
||||
Enter, or ``None`` when the timeout elapses first (EOF too). Ctrl+C
|
||||
propagates as :class:`KeyboardInterrupt`. POSIX polls stdin via ``select``;
|
||||
propagates as :class:`KeyboardInterrupt`; :func:`cancel_prompt_read` (the
|
||||
SIGINT target of an off-loop prompt read) raises
|
||||
:class:`PromptCancelledError` instead. POSIX polls stdin via ``select``;
|
||||
Windows uses ``msvcrt`` char polling (best-effort).
|
||||
"""
|
||||
if timeout < 0:
|
||||
@@ -263,16 +296,18 @@ class NonInteractiveBackend:
|
||||
|
||||
|
||||
_backend: PromptBackend = ClickPromptBackend()
|
||||
_pause_factory: Callable[[], AbstractContextManager[None]] | None = None
|
||||
# CLI hook: context manager around an off-loop prompt. It receives the read's
|
||||
# cancel hook (poll-based reads) or None (reads that cannot be interrupted).
|
||||
_pause_factory: Callable[[Callable[[], None] | None], AbstractContextManager[None]] | None = None
|
||||
_prompt_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def configure_prompting(
|
||||
*,
|
||||
backend: PromptBackend | None = None,
|
||||
pause_factory: Callable[[], AbstractContextManager[None]] | None = None,
|
||||
pause_factory: Callable[[Callable[[], None] | None], AbstractContextManager[None]] | None = None,
|
||||
) -> None:
|
||||
"""Install process-wide prompt backend and optional cancel-pause context."""
|
||||
"""Install process-wide prompt backend and optional SIGINT guard for prompts."""
|
||||
global _backend, _pause_factory # noqa: PLW0603
|
||||
if backend is not None:
|
||||
_backend = backend
|
||||
@@ -284,10 +319,11 @@ def get_prompt_backend() -> PromptBackend:
|
||||
|
||||
|
||||
def reset_prompting() -> None:
|
||||
"""Restore default Click backend and clear pause hook (tests)."""
|
||||
"""Restore default Click backend, clear the pause hook and cancel flag (tests)."""
|
||||
global _backend, _pause_factory # noqa: PLW0603
|
||||
_backend = ClickPromptBackend()
|
||||
_pause_factory = None
|
||||
_prompt_cancel.clear()
|
||||
|
||||
|
||||
def _normalize_options(
|
||||
@@ -464,13 +500,45 @@ def form(
|
||||
backend.echo("Starting over…")
|
||||
|
||||
|
||||
async def run_prompt_async[**P, R](func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
"""Run a blocking prompt off the event loop, serialized, with optional SIGINT pause."""
|
||||
async def _prompt_off_loop[R](
|
||||
func: Callable[..., R],
|
||||
*args: object,
|
||||
cancel_hook: Callable[[], None] | None,
|
||||
**kwargs: object,
|
||||
) -> R:
|
||||
"""Shared body: run *func* off the event loop under the CLI's SIGINT guard."""
|
||||
async with _prompt_lock:
|
||||
if _pause_factory is not None:
|
||||
with _pause_factory():
|
||||
return await asyncio.to_thread(func, *args, **kwargs)
|
||||
return await asyncio.to_thread(func, *args, **kwargs)
|
||||
_prompt_cancel.clear()
|
||||
try:
|
||||
if _pause_factory is not None:
|
||||
with _pause_factory(cancel_hook):
|
||||
return await asyncio.to_thread(func, *args, **kwargs)
|
||||
return await asyncio.to_thread(func, *args, **kwargs)
|
||||
finally:
|
||||
_prompt_cancel.clear()
|
||||
|
||||
|
||||
async def run_prompt_async[**P, R](func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
"""Run a blocking prompt off the event loop, serialized, SIGINT-guarded.
|
||||
|
||||
While the prompt waits, a Ctrl+C must not cancel the enclosing turn: the CLI
|
||||
guard registers the prompt as the SIGINT target. readline-based reads cannot
|
||||
be interrupted without abandoning the reader thread on stdin, so they ignore
|
||||
the interrupt with a one-line hint (see :func:`run_cancellable_prompt_async`
|
||||
for poll-based reads).
|
||||
"""
|
||||
return await _prompt_off_loop(func, *args, cancel_hook=None, **kwargs)
|
||||
|
||||
|
||||
async def run_cancellable_prompt_async[**P, R](func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
"""Run a *poll-based* prompt read off the event loop; Ctrl+C cancels it.
|
||||
|
||||
Only for reads that observe :func:`prompt_cancelled`
|
||||
(``read_line_with_timeout``): the SIGINT raises :class:`PromptCancelledError`
|
||||
in the reader thread, so it returns to the caller instead of leaving a thread
|
||||
blocked on stdin (and without cancelling the enclosing turn).
|
||||
"""
|
||||
return await _prompt_off_loop(func, *args, cancel_hook=cancel_prompt_read, **kwargs)
|
||||
|
||||
|
||||
async def ask_async(prompt: str, *, default: str | None = None) -> str:
|
||||
|
||||
@@ -11,11 +11,15 @@ import asyncio
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
from plyngent.config.models import McpConfig, McpServerConfig
|
||||
|
||||
MCP_PROTOCOL_VERSION = "2025-06-18"
|
||||
@@ -45,6 +49,19 @@ def _client_version() -> str:
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def _isolated_spawn_kwargs() -> dict[str, Any]:
|
||||
"""Spawn kwargs that keep a server out of our process/console group.
|
||||
|
||||
Ctrl+C in the terminal is delivered to every process in the foreground
|
||||
process group (POSIX) / console process group (Windows), so without this a
|
||||
single Ctrl+C during a turn also killed every MCP server — killing in-flight
|
||||
tool calls and leaving the connection dead until ``/mcp reconnect``.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
|
||||
return {"start_new_session": True}
|
||||
|
||||
|
||||
class McpConnectionError(RuntimeError):
|
||||
"""Server process failed to spawn, handshake, or answer in time."""
|
||||
|
||||
@@ -143,6 +160,7 @@ class McpServerConnection:
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=os.environ | self._config.env,
|
||||
cwd=self._config.cwd or None,
|
||||
**_isolated_spawn_kwargs(),
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
self.error = f"spawn {self._config.command!r}: {exc}"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Skill discovery: roots, ``SKILL.md`` metadata, and the read/write helpers."""
|
||||
|
||||
from .discovery import SKILL_FILE as SKILL_FILE
|
||||
from .discovery import Skill as Skill
|
||||
from .discovery import SkillRoot as SkillRoot
|
||||
from .discovery import SkillStore as SkillStore
|
||||
from .discovery import build_roots as build_roots
|
||||
from .discovery import resolve_inside as resolve_inside
|
||||
from .discovery import resolve_skill_file as resolve_skill_file
|
||||
from .discovery import validate_skill_name as validate_skill_name
|
||||
from .frontmatter import FrontmatterError as FrontmatterError
|
||||
from .frontmatter import parse_frontmatter as parse_frontmatter
|
||||
|
||||
__all__ = [
|
||||
"SKILL_FILE",
|
||||
"FrontmatterError",
|
||||
"Skill",
|
||||
"SkillRoot",
|
||||
"SkillStore",
|
||||
"build_roots",
|
||||
"parse_frontmatter",
|
||||
"resolve_inside",
|
||||
"resolve_skill_file",
|
||||
"validate_skill_name",
|
||||
]
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Discover skills under a fixed set of roots.
|
||||
|
||||
A skill is a directory holding a ``SKILL.md``. Roots are ordered by priority
|
||||
(explicit ``[skills].paths`` first, then the built-in sources), so the first
|
||||
skill with a given name wins and the copies it shadows stay visible for
|
||||
reporting rather than disappearing. Parsed metadata is cached against the
|
||||
directory list and its ``SKILL.md`` timestamps, which makes a listing cheap
|
||||
while still noticing an edit made outside the chat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .frontmatter import FrontmatterError, parse_frontmatter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from plyngent.config.models import SkillsConfig
|
||||
|
||||
SKILL_FILE = "SKILL.md"
|
||||
DEFAULT_SKILL_DIR = "skills"
|
||||
# Only the head is needed: frontmatter lives at the top, and ``skill_read``
|
||||
# reads the file itself when the model actually wants the body.
|
||||
_METADATA_READ_BYTES = 64 * 1024
|
||||
_FALLBACK_DESCRIPTION_CHARS = 200
|
||||
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
||||
_FRONTMATTER_NAME = "name"
|
||||
_FRONTMATTER_DESCRIPTION = "description"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SkillRoot:
|
||||
"""One directory scanned for skills.
|
||||
|
||||
``origin`` names where the root came from (``config`` for an explicit path,
|
||||
``plyngent`` for our own user directory, ``claude`` for another harness);
|
||||
``scope`` is ``user`` or ``project``. Together they label a skill's
|
||||
provenance in listings and in the injected catalog.
|
||||
"""
|
||||
|
||||
path: Path
|
||||
origin: str
|
||||
scope: str
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
"""``<origin>-<scope>``, e.g. ``claude-project``."""
|
||||
return f"{self.origin}-{self.scope}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Skill:
|
||||
"""A discovered skill directory and the metadata read from its ``SKILL.md``."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
path: Path
|
||||
root: SkillRoot
|
||||
metadata: Mapping[str, object] = field(default_factory=dict[str, object])
|
||||
# Non-fatal notes (invalid declared name, missing description, …) surfaced to
|
||||
# the human in listings so a third-party skill is never repaired silently.
|
||||
issues: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def skill_file(self) -> Path:
|
||||
return self.path / SKILL_FILE
|
||||
|
||||
@property
|
||||
def declared_name(self) -> str | None:
|
||||
"""The name the frontmatter asked for (may differ from :attr:`name`)."""
|
||||
declared = self.metadata.get(_FRONTMATTER_NAME)
|
||||
return declared.strip() if isinstance(declared, str) and declared.strip() else None
|
||||
|
||||
|
||||
def build_roots(
|
||||
config: SkillsConfig,
|
||||
*,
|
||||
workspace: Path,
|
||||
user_config_dir: Path,
|
||||
home: Path | None = None,
|
||||
) -> list[SkillRoot]:
|
||||
"""Resolve ``[skills]`` into ordered roots (highest priority first).
|
||||
|
||||
Explicit ``paths`` win, then the built-in discovery sources in the order the
|
||||
config lists them. A source contributes only directories that exist *or* are
|
||||
the default write target — a missing root is not an error, it simply holds
|
||||
no skills (and may hold the next created one).
|
||||
"""
|
||||
roots: list[SkillRoot] = []
|
||||
for raw in config.paths:
|
||||
token = raw.strip()
|
||||
if token:
|
||||
roots.append(SkillRoot(path=_expand(token, home=home), origin="config", scope="user"))
|
||||
for source in config.discover:
|
||||
if source == "plyngent":
|
||||
roots.append(SkillRoot(path=user_config_dir / DEFAULT_SKILL_DIR, origin="plyngent", scope="user"))
|
||||
elif source == "claude":
|
||||
if home is not None:
|
||||
roots.append(SkillRoot(path=home / ".claude" / DEFAULT_SKILL_DIR, origin="claude", scope="user"))
|
||||
if config.include_project_roots:
|
||||
project = workspace / ".claude" / DEFAULT_SKILL_DIR
|
||||
roots.append(SkillRoot(path=project, origin="claude", scope="project"))
|
||||
return roots
|
||||
|
||||
|
||||
def _expand(token: str, *, home: Path | None) -> Path:
|
||||
"""Expand a leading ``~`` (against *home* when given) and normalize."""
|
||||
if token.startswith("~"):
|
||||
rest = token[1:].lstrip("/\\")
|
||||
base = home if home is not None else Path.home()
|
||||
return (base / rest) if rest else base
|
||||
return Path(token)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Entry:
|
||||
"""One candidate skill directory plus the timestamp that invalidates the cache."""
|
||||
|
||||
root: SkillRoot
|
||||
directory: Path
|
||||
mtime_ns: int
|
||||
|
||||
|
||||
def validate_skill_name(name: str) -> str | None:
|
||||
"""Return an error message when *name* is not usable as a skill name."""
|
||||
token = name.strip()
|
||||
if not token:
|
||||
return "skill name must not be empty"
|
||||
if not _NAME_RE.match(token):
|
||||
return f"skill name {token!r} must be lowercase letters, digits, and single hyphens (max 64 chars)"
|
||||
return None
|
||||
|
||||
|
||||
def resolve_inside(base: Path, relative: str) -> Path | None:
|
||||
"""Resolve *relative* under *base*; ``None`` when it is absolute or escapes.
|
||||
|
||||
Symlinks are followed by the resolve, so a link pointing outside *base* is
|
||||
rejected instead of becoming a way around the boundary.
|
||||
"""
|
||||
token = relative.strip()
|
||||
if not token:
|
||||
return None
|
||||
candidate = Path(token)
|
||||
if candidate.is_absolute():
|
||||
return None
|
||||
try:
|
||||
root = base.resolve()
|
||||
target = (root / candidate).resolve()
|
||||
_ = target.relative_to(root)
|
||||
except OSError, ValueError:
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
def resolve_skill_file(skill: Skill, relative: str) -> Path | None:
|
||||
"""Resolve *relative* inside *skill*; ``None`` when it escapes the directory.
|
||||
|
||||
The default (empty *relative*) is the skill's own ``SKILL.md``.
|
||||
"""
|
||||
return resolve_inside(skill.path, relative.strip() or SKILL_FILE)
|
||||
|
||||
|
||||
class SkillStore:
|
||||
"""Skills discovered under *roots*, highest-priority root first."""
|
||||
|
||||
_roots: tuple[SkillRoot, ...]
|
||||
_cache: tuple[tuple[tuple[str, int], ...], list[Skill]] | None
|
||||
|
||||
def __init__(self, roots: Sequence[SkillRoot]) -> None:
|
||||
self._roots = tuple(roots)
|
||||
self._cache = None
|
||||
|
||||
@property
|
||||
def roots(self) -> tuple[SkillRoot, ...]:
|
||||
return self._roots
|
||||
|
||||
@property
|
||||
def all_skills(self) -> list[Skill]:
|
||||
"""Every discovered skill, shadowed copies included (priority order)."""
|
||||
return list(self._load())
|
||||
|
||||
@property
|
||||
def skills(self) -> list[Skill]:
|
||||
"""Visible skills by name, sorted for display (shadows removed)."""
|
||||
by_name: dict[str, Skill] = {}
|
||||
for skill in self._load():
|
||||
_ = by_name.setdefault(skill.name, skill)
|
||||
return [by_name[name] for name in sorted(by_name)]
|
||||
|
||||
def shadowed(self) -> dict[str, list[Skill]]:
|
||||
"""Skill names found in more than one root → the losing copies in order."""
|
||||
found: dict[str, list[Skill]] = {}
|
||||
for skill in self._load():
|
||||
found.setdefault(skill.name, []).append(skill)
|
||||
return {name: skills for name, skills in sorted(found.items()) if len(skills) > 1}
|
||||
|
||||
def find(self, name: str) -> Skill | None:
|
||||
"""Look up a visible skill by name (exact, then case-insensitive)."""
|
||||
token = name.strip()
|
||||
wanted = token.casefold()
|
||||
fallback: Skill | None = None
|
||||
for skill in self._load():
|
||||
if skill.name == token:
|
||||
return skill
|
||||
if fallback is None and skill.name.casefold() == wanted:
|
||||
fallback = skill
|
||||
return fallback
|
||||
|
||||
def default_write_root(self) -> SkillRoot | None:
|
||||
"""Root a newly created skill goes into: ours when present, else the first."""
|
||||
for root in self._roots:
|
||||
if root.origin == "plyngent":
|
||||
return root
|
||||
return self._roots[0] if self._roots else None
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Drop the cache so the next access rescans the filesystem."""
|
||||
self._cache = None
|
||||
|
||||
def _load(self) -> list[Skill]:
|
||||
entries = self._scan()
|
||||
signature = tuple((str(entry.directory), entry.mtime_ns) for entry in entries)
|
||||
if self._cache is not None and self._cache[0] == signature:
|
||||
return self._cache[1]
|
||||
skills = [skill for entry in entries if (skill := _read_skill(entry)) is not None]
|
||||
self._cache = (signature, skills)
|
||||
return skills
|
||||
|
||||
def _scan(self) -> list[_Entry]:
|
||||
return [
|
||||
_Entry(root=root, directory=directory, mtime_ns=_skill_mtime(directory))
|
||||
for root in self._roots
|
||||
for directory in _skill_dirs(root.path)
|
||||
]
|
||||
|
||||
|
||||
def _skill_dirs(root: Path) -> list[Path]:
|
||||
"""Skill directories directly under *root* (the root itself may be one)."""
|
||||
try:
|
||||
if not root.is_dir():
|
||||
return []
|
||||
found = [root] if (root / SKILL_FILE).is_file() else []
|
||||
children = sorted(child for child in root.iterdir() if child.is_dir())
|
||||
except OSError:
|
||||
return []
|
||||
found.extend(child for child in children if (child / SKILL_FILE).is_file())
|
||||
return found
|
||||
|
||||
|
||||
def _skill_mtime(directory: Path) -> int:
|
||||
try:
|
||||
return (directory / SKILL_FILE).stat().st_mtime_ns
|
||||
except OSError:
|
||||
return -1
|
||||
|
||||
|
||||
def _read_skill(entry: _Entry) -> Skill | None:
|
||||
"""Read one ``SKILL.md`` head and derive the record; ``None`` when unreadable."""
|
||||
issues: list[str] = []
|
||||
try:
|
||||
text = _read_head(entry.directory / SKILL_FILE)
|
||||
except OSError as exc:
|
||||
return Skill(
|
||||
name=entry.directory.name,
|
||||
description="",
|
||||
path=entry.directory,
|
||||
root=entry.root,
|
||||
issues=(f"cannot read {SKILL_FILE}: {exc}",),
|
||||
)
|
||||
try:
|
||||
metadata, body = parse_frontmatter(text)
|
||||
except FrontmatterError as exc:
|
||||
metadata, body = {}, text
|
||||
issues.append(f"frontmatter not parsed ({exc}); using the directory name")
|
||||
|
||||
name = entry.directory.name
|
||||
declared = metadata.get(_FRONTMATTER_NAME)
|
||||
if isinstance(declared, str) and declared.strip():
|
||||
candidate = declared.strip()
|
||||
if validate_skill_name(candidate) is None:
|
||||
name = candidate
|
||||
else:
|
||||
issues.append(f"declared name {candidate!r} is not usable; using {entry.directory.name!r}")
|
||||
elif declared is not None:
|
||||
issues.append(f"declared name is not a string ({type(declared).__name__}); using {entry.directory.name!r}")
|
||||
|
||||
description = metadata.get(_FRONTMATTER_DESCRIPTION)
|
||||
if isinstance(description, str) and description.strip():
|
||||
text_description = description.strip()
|
||||
else:
|
||||
text_description = _fallback_description(body)
|
||||
issues.append("no frontmatter description; using the first paragraph")
|
||||
return Skill(
|
||||
name=name,
|
||||
description=_one_line(text_description),
|
||||
path=entry.directory,
|
||||
root=entry.root,
|
||||
metadata=metadata,
|
||||
issues=tuple(issues),
|
||||
)
|
||||
|
||||
|
||||
def _read_head(path: Path) -> str:
|
||||
"""Read the head of a file as UTF-8 (frontmatter is at the top)."""
|
||||
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||
return handle.read(_METADATA_READ_BYTES)
|
||||
|
||||
|
||||
def _fallback_description(body: str) -> str:
|
||||
"""First plain paragraph of the body, for skills without a description."""
|
||||
for block in body.split("\n\n"):
|
||||
lines = [line.strip() for line in block.splitlines() if line.strip()]
|
||||
if not lines or lines[0].startswith("#"):
|
||||
continue
|
||||
return " ".join(lines)
|
||||
return ""
|
||||
|
||||
|
||||
def _one_line(text: str) -> str:
|
||||
"""Collapse whitespace and cap the length of a description line."""
|
||||
collapsed = " ".join(text.split())
|
||||
if len(collapsed) <= _FALLBACK_DESCRIPTION_CHARS:
|
||||
return collapsed
|
||||
return collapsed[:_FALLBACK_DESCRIPTION_CHARS].rstrip() + "…"
|
||||
@@ -0,0 +1,196 @@
|
||||
"""YAML-subset frontmatter for ``SKILL.md`` files.
|
||||
|
||||
Skills are shared with other harnesses, which write a small YAML block above the
|
||||
Markdown body. Supporting all of YAML would mean a dependency for a handful of
|
||||
scalar fields, so this module reads the subset those files actually use:
|
||||
top-level ``key: value`` pairs, quoted or plain scalars, booleans and integers,
|
||||
inline ``[a, b]`` lists, block ``- item`` lists, and ``|`` / ``>`` block scalars.
|
||||
A plain scalar folds the indented lines below it (YAML's multi-line form) and a
|
||||
nested mapping is skipped rather than guessed at; anything structurally broken
|
||||
raises :class:`FrontmatterError` so callers can fall back to "no metadata".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_FENCE = "---"
|
||||
# Closing markers accepted by the format (``...`` is the YAML document end).
|
||||
_CLOSERS = frozenset({_FENCE, "..."})
|
||||
_BLOCK_SCALAR_MARKERS = frozenset({"|", "|-", "|+", ">", ">-", ">+"})
|
||||
_TRUE = frozenset({"true", "yes", "on"})
|
||||
_FALSE = frozenset({"false", "no", "off"})
|
||||
_INT = re.compile(r"^-?\d+$")
|
||||
_ITEM = "- "
|
||||
# A quoted scalar needs at least its two quote characters.
|
||||
_QUOTED_MIN_LEN = 2
|
||||
|
||||
|
||||
class FrontmatterError(ValueError):
|
||||
"""Raised when a frontmatter block is outside the supported subset."""
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> tuple[dict[str, object], str]:
|
||||
"""Return ``(metadata, body)`` for a ``SKILL.md`` document.
|
||||
|
||||
No frontmatter (or an unterminated block) yields empty metadata and the whole
|
||||
text as the body.
|
||||
|
||||
Raises:
|
||||
FrontmatterError: The block is outside the supported subset.
|
||||
"""
|
||||
block, body = split_frontmatter(text)
|
||||
if block is None:
|
||||
return {}, body
|
||||
return parse_yaml_subset(block), body.removeprefix("\n")
|
||||
|
||||
|
||||
def split_frontmatter(text: str) -> tuple[str | None, str]:
|
||||
"""Split *text* into its raw frontmatter block (or ``None``) and the body.
|
||||
|
||||
The block must open with a ``---`` line of its own and close with ``---`` or
|
||||
``...``; an unterminated block counts as no frontmatter, so the whole text
|
||||
stays readable as the body.
|
||||
"""
|
||||
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = normalized.split("\n")
|
||||
if not lines or lines[0].strip() != _FENCE:
|
||||
return None, normalized
|
||||
for index, line in enumerate(lines[1:], start=1):
|
||||
if line.strip() in _CLOSERS:
|
||||
return "\n".join(lines[1:index]), "\n".join(lines[index + 1 :])
|
||||
return None, normalized
|
||||
|
||||
|
||||
def parse_yaml_subset(block: str) -> dict[str, object]:
|
||||
"""Parse one frontmatter block into a flat mapping.
|
||||
|
||||
Raises:
|
||||
FrontmatterError: A line is not a top-level ``key: value`` pair.
|
||||
"""
|
||||
metadata: dict[str, object] = {}
|
||||
lines = block.split("\n")
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
raw = lines[index]
|
||||
index += 1
|
||||
stripped = raw.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
if stripped.startswith(_ITEM):
|
||||
msg = f"list item without a key: {raw!r}"
|
||||
raise FrontmatterError(msg)
|
||||
if _indent_of(raw) > 0:
|
||||
msg = f"unexpected indentation: {raw!r}"
|
||||
raise FrontmatterError(msg)
|
||||
key, sep, value = raw.partition(":")
|
||||
if not sep or not key.strip():
|
||||
msg = f"expected 'key: value', got {raw!r}"
|
||||
raise FrontmatterError(msg)
|
||||
name = key.strip()
|
||||
value = value.strip()
|
||||
consumed, index = _consume_indented(lines, index)
|
||||
if value in _BLOCK_SCALAR_MARKERS:
|
||||
metadata[name] = _join_block_scalar(consumed, folded=value.startswith(">"))
|
||||
continue
|
||||
if value:
|
||||
metadata[name] = _scalar(value, consumed)
|
||||
continue
|
||||
items = _list_items(consumed)
|
||||
if items is not None:
|
||||
metadata[name] = items
|
||||
# Empty value with nested mappings: nothing this subset needs to record.
|
||||
return metadata
|
||||
|
||||
|
||||
def _indent_of(line: str) -> int:
|
||||
return len(line) - len(line.lstrip(" "))
|
||||
|
||||
|
||||
def _consume_indented(lines: list[str], index: int) -> tuple[list[str], int]:
|
||||
"""Consume lines indented deeper than the key line; return them and the next index."""
|
||||
consumed: list[str] = []
|
||||
while index < len(lines):
|
||||
line = lines[index]
|
||||
if line.strip() and _indent_of(line) == 0:
|
||||
break
|
||||
consumed.append(line)
|
||||
index += 1
|
||||
return consumed, index
|
||||
|
||||
|
||||
def _dedent(lines: list[str]) -> list[str]:
|
||||
"""Strip the common indentation and trim blank edges."""
|
||||
indents = [_indent_of(line) for line in lines if line.strip()]
|
||||
if not indents:
|
||||
return []
|
||||
width = min(indents)
|
||||
dedented = [line[width:] if len(line) >= width else "" for line in lines]
|
||||
while dedented and not dedented[0].strip():
|
||||
_ = dedented.pop(0)
|
||||
while dedented and not dedented[-1].strip():
|
||||
_ = dedented.pop()
|
||||
return dedented
|
||||
|
||||
|
||||
def _join_block_scalar(lines: list[str], *, folded: bool) -> str:
|
||||
"""Join a ``|`` (literal) or ``>`` (folded) block scalar's content."""
|
||||
body = _dedent(lines)
|
||||
if not body:
|
||||
return ""
|
||||
return " ".join(line.strip() for line in body) if folded else "\n".join(body)
|
||||
|
||||
|
||||
def _list_items(lines: list[str]) -> list[object] | None:
|
||||
"""Parse a block ``- item`` list; ``None`` when *lines* is not one."""
|
||||
meaningful = [line for line in lines if line.strip()]
|
||||
if not meaningful or any(not line.strip().startswith(_ITEM) for line in meaningful):
|
||||
return None
|
||||
items: list[object] = []
|
||||
for line in meaningful:
|
||||
item = line.strip()[len(_ITEM) :].strip()
|
||||
if item:
|
||||
items.append(_coerce(item))
|
||||
return items
|
||||
|
||||
|
||||
def _unquote(text: str) -> str | None:
|
||||
"""Return the contents of a quoted scalar, or ``None`` when it is unquoted."""
|
||||
if len(text) >= _QUOTED_MIN_LEN and text[0] == text[-1] and text[0] in {'"', "'"}:
|
||||
return text[1:-1]
|
||||
return None
|
||||
|
||||
|
||||
def _scalar(value: str, continuation: list[str]) -> object:
|
||||
"""Parse one scalar; a plain scalar folds its indented continuation lines."""
|
||||
if value.startswith("[") and value.endswith("]"):
|
||||
return _inline_list(value)
|
||||
unquoted = _unquote(value)
|
||||
if unquoted is not None:
|
||||
return unquoted
|
||||
folded = [value]
|
||||
folded.extend(line.strip() for line in continuation if line.strip())
|
||||
return _coerce(" ".join(folded))
|
||||
|
||||
|
||||
def _inline_list(value: str) -> list[object]:
|
||||
"""Parse ``[a, "b", 3]`` (no nested collections in this subset)."""
|
||||
inner = value[1:-1].strip()
|
||||
if not inner:
|
||||
return []
|
||||
return [_coerce(part.strip()) for part in inner.split(",") if part.strip()]
|
||||
|
||||
|
||||
def _coerce(text: str) -> object:
|
||||
"""Coerce a plain scalar to bool / int / str (YAML's implicit typing)."""
|
||||
unquoted = _unquote(text)
|
||||
if unquoted is not None:
|
||||
return unquoted
|
||||
lowered = text.lower()
|
||||
if lowered in _TRUE:
|
||||
return True
|
||||
if lowered in _FALSE:
|
||||
return False
|
||||
if _INT.match(text):
|
||||
return int(text)
|
||||
return text
|
||||
@@ -37,10 +37,10 @@ from .file import delete_path as delete_path
|
||||
from .file import edit_lineno as edit_lineno
|
||||
from .file import edit_replace as edit_replace
|
||||
from .file import glob_paths as glob_paths
|
||||
from .file import grep_files as grep_files
|
||||
from .file import listdir as listdir
|
||||
from .file import move_path as move_path
|
||||
from .file import read_file as read_file
|
||||
from .file import regex_files as regex_files
|
||||
from .file import tree as tree
|
||||
from .file import write_file as write_file
|
||||
from .net import NET_TOOLS as NET_TOOLS
|
||||
@@ -60,6 +60,13 @@ from .process import run_argv as run_argv
|
||||
from .process import run_argv_batch as run_argv_batch
|
||||
from .process import write_pty as write_pty
|
||||
from .process import write_pty_keys as write_pty_keys
|
||||
from .skills import SKILL_TOOL_NAMES as SKILL_TOOL_NAMES
|
||||
from .skills import SKILL_TOOLS as SKILL_TOOLS
|
||||
from .skills import format_skill_table as format_skill_table
|
||||
from .skills import get_skill_store as get_skill_store
|
||||
from .skills import skill_list as skill_list
|
||||
from .skills import skill_read as skill_read
|
||||
from .skills import skill_search as skill_search
|
||||
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
|
||||
@@ -106,5 +113,6 @@ 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_static_read_roots as set_static_read_roots
|
||||
from .workspace import set_workspace_root as set_workspace_root
|
||||
from .workspace import within_workspace_roots as within_workspace_roots
|
||||
|
||||
@@ -223,6 +223,7 @@ def _ensure_builtin_definitions_registered(catalog: ToolCatalog) -> None:
|
||||
from plyngent.tools.file import FILE_TOOLS
|
||||
from plyngent.tools.net import NET_TOOLS
|
||||
from plyngent.tools.process import PROCESS_TOOLS
|
||||
from plyngent.tools.skills import SKILL_TOOLS
|
||||
from plyngent.tools.todo import TODO_TOOLS
|
||||
from plyngent.tools.vcs import VCS_TOOLS
|
||||
|
||||
@@ -233,6 +234,7 @@ def _ensure_builtin_definitions_registered(catalog: ToolCatalog) -> None:
|
||||
*VCS_TOOLS,
|
||||
*CHAT_TOOLS,
|
||||
*TODO_TOOLS,
|
||||
*SKILL_TOOLS,
|
||||
*NET_TOOLS,
|
||||
):
|
||||
if catalog.get(definition.name) is None:
|
||||
@@ -254,6 +256,7 @@ def register_builtin_tools(*, force: bool = False) -> ToolCatalog:
|
||||
import plyngent.tools.file as _file_tools
|
||||
import plyngent.tools.net as _net_tools
|
||||
import plyngent.tools.process as _process_tools
|
||||
import plyngent.tools.skills as _skills_tools
|
||||
import plyngent.tools.temp_workspace as _temp_workspace_tools
|
||||
import plyngent.tools.todo as _todo_tools
|
||||
import plyngent.tools.vcs as _vcs_tools
|
||||
@@ -263,6 +266,7 @@ def register_builtin_tools(*, force: bool = False) -> ToolCatalog:
|
||||
_file_tools,
|
||||
_net_tools,
|
||||
_process_tools,
|
||||
_skills_tools,
|
||||
_temp_workspace_tools,
|
||||
_todo_tools,
|
||||
_vcs_tools,
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.prompting import NonInteractiveError, ask_async
|
||||
from plyngent.tools.chat.shape import first_error, string_error
|
||||
|
||||
|
||||
@tool(name="ask_user_line", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
|
||||
@@ -12,6 +13,12 @@ async def ask_user(question: str, default: str = "") -> str:
|
||||
or any input that is not a fixed menu. Optional ``default`` is used if the
|
||||
user submits empty input (and in non-interactive mode when provided).
|
||||
"""
|
||||
shape_error = first_error(
|
||||
string_error("ask_user_line", "question", question),
|
||||
string_error("ask_user_line", "default", default),
|
||||
)
|
||||
if shape_error is not None:
|
||||
return f"error: {shape_error}"
|
||||
try:
|
||||
return await ask_async(question, default=default or None)
|
||||
except NonInteractiveError as exc:
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import cast
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.prompting import ChoiceOption, NonInteractiveError, choose_async
|
||||
from plyngent.tools.chat.shape import first_error, list_error, string_error
|
||||
|
||||
|
||||
def parse_options(options: list[object]) -> list[ChoiceOption]:
|
||||
@@ -51,6 +52,19 @@ async def choose_user(
|
||||
When ``allow_custom`` is true (default), free-text answers are accepted.
|
||||
Returns the chosen option value (or custom text).
|
||||
"""
|
||||
shape_error = first_error(
|
||||
string_error("ask_user_choice", "question", question),
|
||||
list_error(
|
||||
"ask_user_choice",
|
||||
"options",
|
||||
options,
|
||||
items="strings or objects with a label",
|
||||
example='["alpha", "beta"]',
|
||||
),
|
||||
string_error("ask_user_choice", "default", default),
|
||||
)
|
||||
if shape_error is not None:
|
||||
return f"error: {shape_error}"
|
||||
try:
|
||||
parsed = parse_options([cast("object", o) for o in options])
|
||||
except (TypeError, ValueError) as exc:
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import cast
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.prompting import FormField, NonInteractiveError, form_async
|
||||
from plyngent.tools.chat.choose import parse_options
|
||||
from plyngent.tools.chat.shape import first_error, list_error, string_error
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
|
||||
def parse_fields(fields: list[dict[str, object]]) -> list[FormField]:
|
||||
def parse_fields(fields: Sequence[object]) -> list[FormField]:
|
||||
"""Normalize a list of field dicts into ``FormField`` list."""
|
||||
if not fields:
|
||||
msg = "fields must be a non-empty list"
|
||||
raise ValueError(msg)
|
||||
out: list[FormField] = []
|
||||
for item_obj in fields:
|
||||
raw_map = {str(key): value for key, value in item_obj.items()}
|
||||
if not isinstance(item_obj, dict):
|
||||
msg = "each field must be an object with name/prompt"
|
||||
raise TypeError(msg)
|
||||
raw_map = {str(key): value for key, value in cast("dict[object, object]", item_obj).items()}
|
||||
name = raw_map.get("name")
|
||||
prompt = raw_map.get("prompt")
|
||||
if not isinstance(name, str) or not name:
|
||||
@@ -61,6 +68,18 @@ async def form_user(
|
||||
optional ``allow_custom`` (default true).
|
||||
When ``confirm_submit`` is true, the human reviews a summary before submit.
|
||||
"""
|
||||
shape_error = first_error(
|
||||
string_error("ask_user_form", "title", title),
|
||||
list_error(
|
||||
"ask_user_form",
|
||||
"fields",
|
||||
fields,
|
||||
items="objects",
|
||||
example='[{"name": "port", "prompt": "Which port?"}]',
|
||||
),
|
||||
)
|
||||
if shape_error is not None:
|
||||
return f"error: {shape_error}"
|
||||
try:
|
||||
parsed = parse_fields(fields)
|
||||
except (TypeError, ValueError) as exc:
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Shape checks for ask-family tool arguments.
|
||||
|
||||
Tool arguments arrive as decoded JSON, so a parameter declared ``list[str]`` can
|
||||
still be a plain string at runtime. Duck-typing then misleads instead of failing:
|
||||
``ask_user_choice(options="yes/no")`` iterates the characters and shows a menu of
|
||||
letters, and a string ``fields`` dies inside the form parser with an
|
||||
``AttributeError``. Each ask-family tool checks its container arguments up front
|
||||
and answers with a model-facing error that also shows the expected shape (same
|
||||
idea as :func:`plyngent.tools.workspace.argv_shape_error`).
|
||||
|
||||
Every check takes ``object``, so the isinstance calls stay meaningful and the
|
||||
tools themselves stay free of unnecessary-isinstance warnings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_SHAPES: dict[type, str] = {
|
||||
str: "a string",
|
||||
list: "an array",
|
||||
dict: "an object",
|
||||
bool: "a boolean",
|
||||
int: "a number",
|
||||
float: "a number",
|
||||
type(None): "null",
|
||||
}
|
||||
|
||||
|
||||
def shape_of(value: object) -> str:
|
||||
"""Model-facing name for a decoded JSON value (``a string``, ``an array``…)."""
|
||||
return _SHAPES.get(type(value), type(value).__name__)
|
||||
|
||||
|
||||
def first_error(*errors: str | None) -> str | None:
|
||||
"""First non-``None`` error (``None`` when every shape was acceptable)."""
|
||||
return next((error for error in errors if error), None)
|
||||
|
||||
|
||||
def string_error(tool: str, field: str, value: object) -> str | None:
|
||||
"""Error when *value* must be a string but is not; ``None`` when it is."""
|
||||
if isinstance(value, str):
|
||||
return None
|
||||
return f"`{tool}` expects `{field}` as a string, got {shape_of(value)}"
|
||||
|
||||
|
||||
def list_error(tool: str, field: str, value: object, *, items: str, example: str) -> str | None:
|
||||
"""Error when *value* must be a JSON array of *items* but is not; else ``None``.
|
||||
|
||||
Only the container is checked: item-level validation stays in the tool's own
|
||||
parser (``parse_options`` / ``parse_fields``), which already reports what is
|
||||
wrong with a bad entry.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return f"`{tool}` expects `{field}` as a JSON array of {items}, not a string; pass e.g. {example}"
|
||||
if not isinstance(value, list):
|
||||
return f"`{tool}` expects `{field}` as a JSON array of {items}, got {shape_of(value)}; pass e.g. {example}"
|
||||
return None
|
||||
|
||||
|
||||
def number_arg(tool: str, field: str, value: object, *, example: str) -> tuple[float | None, str | None]:
|
||||
"""Coerce *value* to a float, accepting numeric strings; else ``(None, error)``.
|
||||
|
||||
Models often send numbers as strings, which is accepted here. Booleans are
|
||||
not numbers for this purpose, and neither is non-numeric text.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return None, f"`{tool}` expects `{field}` as a number, got a boolean; pass e.g. {example}"
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value), None
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return float(value.strip()), None
|
||||
except ValueError:
|
||||
return None, f"`{tool}` expects `{field}` as a number, got a non-numeric string; pass e.g. {example}"
|
||||
return None, f"`{tool}` expects `{field}` as a number, got {shape_of(value)}; pass e.g. {example}"
|
||||
@@ -7,12 +7,18 @@ import sys
|
||||
from click import style
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.prompting import get_prompt_backend, read_line_with_timeout, run_prompt_async
|
||||
from plyngent.prompting import (
|
||||
PromptCancelledError,
|
||||
get_prompt_backend,
|
||||
read_line_with_timeout,
|
||||
run_cancellable_prompt_async,
|
||||
)
|
||||
from plyngent.tools.chat.shape import first_error, number_arg, string_error
|
||||
|
||||
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
def _wait_prompt(duration: int, *, reason: str | None) -> str:
|
||||
def _wait_prompt(duration: float, *, reason: str | None) -> str:
|
||||
"""Two-line wait prompt: status line, then the optional-reason input prompt.
|
||||
|
||||
``read_line_with_timeout`` writes the prompt verbatim to stdout (no click
|
||||
@@ -20,7 +26,7 @@ def _wait_prompt(duration: int, *, reason: str | None) -> str:
|
||||
is not a TTY. On a terminal: cyan status, dimmed detail, yellow input
|
||||
prompt; plain text otherwise (tests, redirected output).
|
||||
"""
|
||||
status = style(f"Waiting for {duration}s", fg="cyan")
|
||||
status = style(f"Waiting for {duration:g}s", fg="cyan")
|
||||
if reason:
|
||||
status += style(f" ({reason})", fg="bright_black")
|
||||
status += style(". Press Enter to disturb.", fg="bright_black")
|
||||
@@ -31,28 +37,44 @@ def _wait_prompt(duration: int, *, reason: str | None) -> str:
|
||||
return rendered
|
||||
|
||||
|
||||
async def _interactive_wait(seconds: float, reason: str | None) -> str:
|
||||
"""Show the two-line wait prompt and report how the wait ended."""
|
||||
label = f"{seconds:g}"
|
||||
prompt = _wait_prompt(seconds, reason=reason)
|
||||
try:
|
||||
line = await run_cancellable_prompt_async(read_line_with_timeout, prompt, seconds)
|
||||
except PromptCancelledError:
|
||||
return "cancelled by user"
|
||||
if line is None:
|
||||
return f"waited {label}s"
|
||||
text = line.strip()
|
||||
if text:
|
||||
return f"disturbed by user: {text}"
|
||||
return "disturbed by user (no reason)"
|
||||
|
||||
|
||||
@tool(name="wait", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
|
||||
async def wait(duration: int, *, reason: str | None = None) -> str:
|
||||
"""Wait ``duration`` seconds before continuing.
|
||||
|
||||
Interactive sessions show a two-line prompt: a status line, then an
|
||||
optional-reason input — pressing Enter (optionally after typing a reason)
|
||||
"disturbs" the wait so the turn continues immediately. Non-interactive runs
|
||||
simply sleep the full duration.
|
||||
"disturbs" the wait so the turn continues immediately, and Ctrl+C cancels
|
||||
the wait (the turn continues). Non-interactive runs simply sleep the full
|
||||
duration.
|
||||
"""
|
||||
if duration < 0:
|
||||
seconds, duration_error = number_arg("wait", "duration", duration, example="5")
|
||||
reason_error = None if reason is None else string_error("wait", "reason", reason)
|
||||
shape_error = first_error(duration_error, reason_error)
|
||||
if shape_error is not None:
|
||||
return f"error: {shape_error}"
|
||||
assert seconds is not None # number_arg returns a value whenever it accepts
|
||||
if seconds < 0:
|
||||
return "error: duration must not be negative"
|
||||
if duration == 0:
|
||||
if seconds == 0:
|
||||
return "waited 0s"
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
await asyncio.sleep(duration)
|
||||
return f"waited {duration}s"
|
||||
prompt = _wait_prompt(duration, reason=reason)
|
||||
line = await run_prompt_async(read_line_with_timeout, prompt, float(duration))
|
||||
if line is None:
|
||||
return f"waited {duration}s"
|
||||
text = line.strip()
|
||||
if text:
|
||||
return f"disturbed by user: {text}"
|
||||
return "disturbed by user (no reason)"
|
||||
await asyncio.sleep(seconds)
|
||||
return f"waited {seconds:g}s"
|
||||
return await _interactive_wait(seconds, reason)
|
||||
|
||||
@@ -14,6 +14,7 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.agent.todo_stack import TodoStack
|
||||
from plyngent.skills import SkillStore
|
||||
from plyngent.tools.workspace import AccessMode, WorkspacePolicy
|
||||
|
||||
|
||||
@@ -40,6 +41,8 @@ class InstanceState:
|
||||
# Path/command policy bag for this host (preferred over process globals).
|
||||
workspace: WorkspacePolicy = field(default_factory=_default_workspace_policy)
|
||||
data: PersistentDataView[Any] = field(default_factory=_default_instance_data)
|
||||
# Discovered skills for this host (None when the feature is off).
|
||||
skills: SkillStore | None = None
|
||||
# Ephemeral process maps (PTY etc.) may hang here later.
|
||||
extras: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@@ -83,6 +86,9 @@ class SessionState:
|
||||
# Directory-access grants for this session (resolved path → mode). Config /
|
||||
# process-scoped grants live on WorkspacePolicy; see plyngent.tools.access.
|
||||
access_grants: dict[Path, AccessMode] = field(default_factory=dict)
|
||||
# Skill directories the human allowed this chat to write (resolved paths);
|
||||
# the standing grant lives in [skills].allow_write (tools/skills.py).
|
||||
skill_write_roots: set[Path] = field(default_factory=set)
|
||||
|
||||
def has_grant(self, tool_name: str) -> bool:
|
||||
return bool(self.grants.get(tool_name))
|
||||
|
||||
@@ -7,9 +7,9 @@ from .fs_ops import delete_path as delete_path
|
||||
from .fs_ops import move_path as move_path
|
||||
from .get_truncated import get_truncated as get_truncated
|
||||
from .glob_paths import glob_paths as glob_paths
|
||||
from .grep_files import grep_files as grep_files
|
||||
from .listdir import listdir as listdir
|
||||
from .read import read_file as read_file
|
||||
from .regex_files import regex_files as regex_files
|
||||
from .tree import tree as tree
|
||||
from .write import write_file as write_file
|
||||
|
||||
@@ -19,7 +19,7 @@ FILE_TOOLS = [
|
||||
listdir,
|
||||
tree,
|
||||
glob_paths,
|
||||
grep_files,
|
||||
regex_files,
|
||||
edit_replace,
|
||||
edit_lineno,
|
||||
copy_path,
|
||||
|
||||
@@ -103,7 +103,9 @@ def _match_lines(
|
||||
try:
|
||||
rel = str(file_path.resolve().relative_to(root))
|
||||
except ValueError:
|
||||
return
|
||||
# Outside the primary root (a grant / temp workspace / skill directory):
|
||||
# keep the absolute path so the hit stays addressable.
|
||||
rel = str(file_path.resolve())
|
||||
found = 0
|
||||
for line_no, line in enumerate(text.splitlines(), start=1):
|
||||
if found >= remaining:
|
||||
@@ -128,7 +130,7 @@ def _compile_pattern(pattern: str, *, case_insensitive: bool) -> re.Pattern[str]
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||
async def grep_files(
|
||||
async def regex_files(
|
||||
pattern: str,
|
||||
path: str = ".",
|
||||
*,
|
||||
@@ -138,8 +140,22 @@ async def grep_files(
|
||||
) -> str:
|
||||
"""Search file contents under the workspace with a regular expression.
|
||||
|
||||
Returns ``path:line: content`` lines (paths relative to workspace root).
|
||||
Skips VCS/hidden dirs by default and ignores likely-binary files.
|
||||
``pattern`` is a **Python** ``re`` regex matched per line (``re.MULTILINE``),
|
||||
**not** a filename glob — ``*.py`` / ``**/*.py`` are invalid regexes and fail
|
||||
with ``error: invalid regex``. There is no file-type filter either: narrow with
|
||||
``path`` (a file or a directory) and filter unrelated hits yourself.
|
||||
``case_insensitive`` (or an inline ``(?i)``) ignores case.
|
||||
|
||||
``path`` is a **literal** file or directory (default ``.`` = workspace root);
|
||||
globs are not expanded, so ``src/**/*.py`` fails with ``error: path does not
|
||||
exist`` — pass ``src`` instead.
|
||||
|
||||
Returns ``<path>:<line>: <content>`` lines (workspace-relative, absolute outside
|
||||
the workspace), long lines clipped with ``…``, capped at ``max_matches``
|
||||
(default 100, plus a ``...[truncated at N matches]`` marker). VCS/hidden dirs are
|
||||
skipped unless ``skip_hidden_dirs=False``; files over 1 MB and likely-binary
|
||||
files are ignored. Returns ``(no matches)`` when nothing matched; an invalid
|
||||
``pattern`` or a missing ``path`` returns an ``error: ...`` line.
|
||||
"""
|
||||
if max_matches < 1:
|
||||
return "error: max_matches must be >= 1"
|
||||
@@ -54,7 +54,7 @@ async def ask_into_pty(
|
||||
|
||||
``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.
|
||||
newline after the answer. Empty input cancels without writing.
|
||||
"""
|
||||
label = message.strip() or ("Secret" if secret else "Input")
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
"""Skill tools: discover, read, search, create, and edit skill directories.
|
||||
|
||||
A skill is a directory holding a ``SKILL.md`` (frontmatter + Markdown body) plus
|
||||
optional bundled files — scripts, references, templates. Discovery, parsing, and
|
||||
path rules live in :mod:`plyngent.skills`; this module is the model-facing shell.
|
||||
|
||||
Reading needs no grant: the host registers the roots as static read-only roots,
|
||||
so ``read_file`` / ``regex_files`` see them too. Writing goes through the skill
|
||||
tools' own grant (a per-call confirm, or ``[skills].allow_write``) and is always
|
||||
confined to a skill root — that is why these tools never inherit the
|
||||
directory-access path of ``request_directory_access``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
|
||||
from plyngent.skills import (
|
||||
SKILL_FILE,
|
||||
FrontmatterError,
|
||||
Skill,
|
||||
SkillStore,
|
||||
parse_frontmatter,
|
||||
resolve_inside,
|
||||
resolve_skill_file,
|
||||
validate_skill_name,
|
||||
)
|
||||
from plyngent.tools.file.read import read_file as _read_file
|
||||
from plyngent.tools.file.regex_files import DEFAULT_MAX_MATCHES
|
||||
from plyngent.tools.file.regex_files import regex_files as _regex_files
|
||||
from plyngent.tools.workspace import (
|
||||
DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS,
|
||||
WorkspaceError,
|
||||
get_workspace_root,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from plyngent.tools.context import InstanceState, SessionState
|
||||
|
||||
# Keeps a listing readable when a third-party root holds a pile of skills.
|
||||
_MAX_LISTED_ISSUES = 8
|
||||
_SKILL_WRITE_POLICY_KEY = "skill_write_policy"
|
||||
# ``path:line: content`` as ``regex_files`` formats it (the path is non-greedy so
|
||||
# a Windows drive letter or a colon in a file name cannot split it wrongly).
|
||||
_HIT_RE = re.compile(r"^(?P<path>.+?):(?P<line>\d+): ?(?P<content>.*)$")
|
||||
|
||||
|
||||
def get_skill_store() -> SkillStore | None:
|
||||
"""The skill store bound on the host instance, if any."""
|
||||
from plyngent.tools.context import get_instance
|
||||
|
||||
instance = get_instance()
|
||||
return instance.skills if instance is not None else None
|
||||
|
||||
|
||||
def _store_or_error() -> SkillStore | str:
|
||||
store = get_skill_store()
|
||||
if store is None:
|
||||
return "error: skills are not available in this session"
|
||||
return store
|
||||
|
||||
|
||||
def format_skill_table(store: SkillStore) -> str:
|
||||
"""Model- and human-facing listing: visible skills, shadows, roots, notes.
|
||||
|
||||
Shadowed copies and per-skill notes are printed rather than hidden: a skill
|
||||
that lost a name collision, or whose frontmatter had to be repaired, must
|
||||
stay visible to whoever can fix it.
|
||||
"""
|
||||
skills = store.skills
|
||||
lines = [f"skills: {len(skills)}"]
|
||||
width = max((len(skill.name) for skill in skills), default=0)
|
||||
for skill in skills:
|
||||
description = skill.description or "(no description)"
|
||||
lines.append(f"{skill.name.ljust(width)} [{skill.root.label}] {skill.path} — {description}")
|
||||
|
||||
shadows = store.shadowed()
|
||||
if shadows:
|
||||
parts: list[str] = []
|
||||
for name, copies in shadows.items():
|
||||
others = ", ".join(copy.root.label for copy in copies[1:])
|
||||
parts.append(f"{name} — {copies[0].root.label} wins (also {others})")
|
||||
lines.append("shadowed: " + "; ".join(parts))
|
||||
|
||||
notes = [f"{skill.name}: {issue}" for skill in skills for issue in skill.issues]
|
||||
if notes:
|
||||
lines.append("notes: " + "; ".join(notes[:_MAX_LISTED_ISSUES]))
|
||||
if len(notes) > _MAX_LISTED_ISSUES:
|
||||
lines.append(f"notes: … and {len(notes) - _MAX_LISTED_ISSUES} more")
|
||||
|
||||
absent = [root for root in store.roots if not root.path.is_dir()]
|
||||
if absent:
|
||||
lines.append("roots without a directory: " + ", ".join(f"{root.path} ({root.label})" for root in absent))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _unknown_skill(name: str) -> str:
|
||||
return f"error: unknown skill {name!r}; call skill_list to see the discovered skills"
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||
async def skill_list() -> str:
|
||||
"""List discovered skills: name, source root, directory, and description.
|
||||
|
||||
Skills come from the roots in ``[skills]`` (our own user directory and other
|
||||
harnesses' skill directories by default); the first copy of a name wins and
|
||||
the copies it shadows are listed too. A skill's ``SKILL.md`` is the
|
||||
instruction set — read one with ``skill_read`` before following it, and use
|
||||
``skill_search`` to find a skill by its content.
|
||||
"""
|
||||
store = _store_or_error()
|
||||
if isinstance(store, str):
|
||||
return store
|
||||
return format_skill_table(store)
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||
async def skill_read(
|
||||
name: str,
|
||||
file: str = SKILL_FILE,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int | None = None,
|
||||
with_lineno: bool = False,
|
||||
max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||
) -> str:
|
||||
"""Read a skill's ``SKILL.md`` (default) or one of its bundled files.
|
||||
|
||||
``name`` is the skill name from ``skill_list``; ``file`` is a path inside
|
||||
that skill (a script or reference it mentions). ``offset`` is a 0-based line
|
||||
start, ``with_lineno`` prefixes 1-based line numbers, and ``max_chars`` caps
|
||||
the slice with a ``truncate_token`` for ``get_truncated`` to continue.
|
||||
"""
|
||||
store = _store_or_error()
|
||||
if isinstance(store, str):
|
||||
return store
|
||||
skill = store.find(name)
|
||||
if skill is None:
|
||||
return _unknown_skill(name)
|
||||
target = resolve_skill_file(skill, file)
|
||||
if target is None:
|
||||
return f"error: file {file!r} is outside skill {skill.name!r}"
|
||||
if not target.is_file():
|
||||
return f"error: no such file in skill {skill.name!r}: {file}"
|
||||
return await _read_file.handler(
|
||||
str(target),
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
with_lineno=with_lineno,
|
||||
max_chars=max_chars,
|
||||
)
|
||||
|
||||
|
||||
def _hit_relative(skill: Skill, path: str) -> str:
|
||||
"""Rebase one ``regex_files`` hit path onto its skill directory.
|
||||
|
||||
The search reports paths relative to the workspace root for files inside it and
|
||||
absolute paths for files outside, so a skill directory can arrive either way
|
||||
(a project-scope skill root lives inside the workspace). Normalizing to an
|
||||
absolute path first covers both without guessing from the string alone.
|
||||
"""
|
||||
candidate = Path(path)
|
||||
if not candidate.is_absolute():
|
||||
try:
|
||||
candidate = get_workspace_root() / candidate
|
||||
except WorkspaceError:
|
||||
return path
|
||||
try:
|
||||
return str(candidate.resolve().relative_to(skill.path.resolve()))
|
||||
except OSError, ValueError:
|
||||
return path
|
||||
|
||||
|
||||
def _skill_hits(skill: Skill, result: str) -> list[str]:
|
||||
"""Attribute one skill's search lines to that skill."""
|
||||
hits: list[str] = []
|
||||
for line in result.splitlines():
|
||||
match = _HIT_RE.match(line)
|
||||
if match is None:
|
||||
continue
|
||||
relative = _hit_relative(skill, match["path"])
|
||||
hits.append(f"{skill.name}/{relative}:{match['line']}: {match['content']}")
|
||||
return hits
|
||||
|
||||
|
||||
def _search_targets(store: SkillStore, skill: str) -> list[Skill] | str:
|
||||
"""Every visible skill, or the single named one."""
|
||||
if not skill.strip():
|
||||
return store.skills
|
||||
found = store.find(skill)
|
||||
if found is None:
|
||||
return _unknown_skill(skill)
|
||||
return [found]
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||
async def skill_search(
|
||||
pattern: str,
|
||||
skill: str = "",
|
||||
*,
|
||||
case_insensitive: bool = False,
|
||||
max_matches: int = DEFAULT_MAX_MATCHES,
|
||||
) -> str:
|
||||
"""Search skill contents with a regular expression.
|
||||
|
||||
Returns ``<skill>/<file>:<line>: content`` lines across every visible skill
|
||||
(or only *skill* when given), covering ``SKILL.md`` and any bundled files.
|
||||
Use ``skill_list`` for names and descriptions, and ``glob_paths`` on a
|
||||
skill's directory when you are looking for a file rather than its content.
|
||||
"""
|
||||
store = _store_or_error()
|
||||
if isinstance(store, str):
|
||||
return store
|
||||
targets = _search_targets(store, skill)
|
||||
if isinstance(targets, str):
|
||||
return targets
|
||||
if not targets:
|
||||
return "(no skills to search)"
|
||||
|
||||
hits: list[str] = []
|
||||
for target in targets:
|
||||
remaining = max_matches - len(hits)
|
||||
if remaining <= 0:
|
||||
break
|
||||
result = await _regex_files.handler(
|
||||
pattern,
|
||||
str(target.path),
|
||||
case_insensitive=case_insensitive,
|
||||
max_matches=remaining,
|
||||
)
|
||||
if result.startswith("error:") or result == "(no matches)":
|
||||
if result.startswith("error:"):
|
||||
return result
|
||||
continue
|
||||
hits.extend(_skill_hits(target, result))
|
||||
|
||||
if not hits:
|
||||
return "(no matches)"
|
||||
body = "\n".join(hits[:max_matches])
|
||||
if len(hits) >= max_matches:
|
||||
body += f"\n...[truncated at {max_matches} matches]"
|
||||
return body
|
||||
|
||||
|
||||
# -- writing (needs a grant) -------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SkillWriteDecision:
|
||||
"""A human's answer to a skill write prompt: allow, and for how long."""
|
||||
|
||||
allow: bool
|
||||
# True keeps the grant for the rest of this chat session (the standing
|
||||
# alternative is ``[skills].allow_write`` in the config).
|
||||
session: bool = False
|
||||
|
||||
|
||||
type SkillWriteConfirmHook = Callable[[Path, str, str, float], SkillWriteDecision | bool | None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SkillWritePolicy:
|
||||
"""How this host authorizes writes to skill directories.
|
||||
|
||||
``allow_all`` is the standing grant (``[skills].allow_write``). Otherwise the
|
||||
host's ``confirm`` hook is asked per skill directory; a host that installs no
|
||||
hook denies, which keeps the library default safe.
|
||||
"""
|
||||
|
||||
allow_all: bool = False
|
||||
confirm: SkillWriteConfirmHook | None = None
|
||||
timeout: float = DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def set_skill_write_policy(policy: SkillWritePolicy | None, *, instance: InstanceState | None = None) -> None:
|
||||
"""Install (or clear) the host's skill-write policy on the bound instance."""
|
||||
from plyngent.tools.context import require_instance
|
||||
|
||||
inst = instance if instance is not None else require_instance()
|
||||
if policy is None:
|
||||
_ = inst.extras.pop(_SKILL_WRITE_POLICY_KEY, None)
|
||||
return
|
||||
inst.extras[_SKILL_WRITE_POLICY_KEY] = policy
|
||||
|
||||
|
||||
def get_skill_write_policy() -> SkillWritePolicy | None:
|
||||
"""The policy installed on the bound instance, if any."""
|
||||
from plyngent.tools.context import get_instance
|
||||
|
||||
instance = get_instance()
|
||||
return cast("SkillWritePolicy | None", instance.extras.get(_SKILL_WRITE_POLICY_KEY)) if instance else None
|
||||
|
||||
|
||||
def _denied_by_policy(*paths: Path) -> str | None:
|
||||
"""Hard path denylist for a write target (never skippable by a grant)."""
|
||||
from plyngent.tools.workspace import denylist_match
|
||||
|
||||
for path in paths:
|
||||
for candidate, directory in ((path, path.is_dir()), (path.parent, True)):
|
||||
matched = denylist_match(candidate, directory=directory)
|
||||
if matched is not None:
|
||||
return f"error: path denied by policy (matched {matched!r}): {path}"
|
||||
return None
|
||||
|
||||
|
||||
def _prompt_write(
|
||||
skill_dir: Path,
|
||||
*,
|
||||
action: str,
|
||||
detail: str,
|
||||
policy: SkillWritePolicy,
|
||||
session: SessionState | None,
|
||||
) -> str | None:
|
||||
"""Ask the host's hook and record a session grant when the human chose one."""
|
||||
hook = policy.confirm
|
||||
if hook is None:
|
||||
return None
|
||||
try:
|
||||
decision = hook(skill_dir, action, detail, policy.timeout)
|
||||
except Exception as exc: # noqa: BLE001 — surface a failing prompt to the model
|
||||
return f"error: skill write confirm failed: {exc}"
|
||||
allowed = decision is True or (isinstance(decision, SkillWriteDecision) and decision.allow)
|
||||
if not allowed:
|
||||
return f"error: skill write to {skill_dir} denied (declined or timed out after {policy.timeout:g}s)"
|
||||
if isinstance(decision, SkillWriteDecision) and decision.session and session is not None:
|
||||
session.skill_write_roots.add(skill_dir)
|
||||
return None
|
||||
|
||||
|
||||
def _authorize_write(skill_dir: Path, target: Path, *, action: str, detail: str) -> str | None:
|
||||
"""``None`` when a write to *target* inside *skill_dir* is authorized.
|
||||
|
||||
Grants are per **skill directory** — that is the unit the human is asked
|
||||
about, and one answer covers every file of that skill. The denylist is
|
||||
checked first and always wins; then a session grant, then
|
||||
``[skills].allow_write``, then the host's confirm hook. Without a hook the
|
||||
write is denied rather than silently allowed.
|
||||
"""
|
||||
denied = _denied_by_policy(target, skill_dir)
|
||||
if denied is not None:
|
||||
return denied
|
||||
from plyngent.tools.context import get_session
|
||||
|
||||
session = get_session()
|
||||
if session is not None and skill_dir in session.skill_write_roots:
|
||||
return None
|
||||
policy = get_skill_write_policy()
|
||||
if policy is None or (policy.confirm is None and not policy.allow_all):
|
||||
return (
|
||||
"error: writing skills needs a grant (no confirm hook is installed; denied); "
|
||||
"set [skills].allow_write = true to allow skill writes"
|
||||
)
|
||||
if policy.allow_all:
|
||||
return None
|
||||
return _prompt_write(skill_dir, action=action, detail=detail, policy=policy, session=session)
|
||||
|
||||
|
||||
def _frontmatter_scalar(text: str) -> str:
|
||||
"""One-line, quoted-if-needed frontmatter value for generated SKILL.md files."""
|
||||
one_line = " ".join(text.split())
|
||||
if not one_line:
|
||||
return '""'
|
||||
if any(char in one_line for char in ":#{}[]\"'"):
|
||||
return '"' + one_line.replace('"', "'") + '"'
|
||||
return one_line
|
||||
|
||||
|
||||
def _render_skill_md(name: str, description: str, body: str) -> str:
|
||||
"""Compose a minimal ``SKILL.md``: name, description, then the body."""
|
||||
front = f"---\nname: {name}\ndescription: {_frontmatter_scalar(description)}\n---\n\n"
|
||||
return front + body.strip() + "\n"
|
||||
|
||||
|
||||
def _skill_notes(skill_file: Path) -> str:
|
||||
"""Non-fatal notes after editing a ``SKILL.md`` (parse errors, missing fields)."""
|
||||
try:
|
||||
metadata, _body = parse_frontmatter(skill_file.read_text(encoding="utf-8", errors="replace"))
|
||||
except (FrontmatterError, OSError) as exc:
|
||||
return f"\nnotes: SKILL.md frontmatter not parsed ({exc}); it will use the directory name"
|
||||
description = metadata.get("description")
|
||||
if not isinstance(description, str) or not description.strip():
|
||||
return "\nnotes: SKILL.md has no frontmatter description; the first paragraph will be used"
|
||||
return ""
|
||||
|
||||
|
||||
def _write_file(target: Path, content: str) -> None:
|
||||
"""Write *content* under a skill, creating parent directories."""
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
_ = target.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def _create_target(store: SkillStore, name: str) -> tuple[Path, str] | str:
|
||||
"""Resolve the directory a new *name* goes into; error string when unusable."""
|
||||
problem = validate_skill_name(name)
|
||||
if problem is not None:
|
||||
return f"error: {problem}"
|
||||
root = store.default_write_root()
|
||||
if root is None:
|
||||
return "error: no skill root is configured; set [skills].paths or [skills].discover"
|
||||
token = name.strip()
|
||||
target = root.path / token
|
||||
if (target / SKILL_FILE).is_file():
|
||||
return f"error: skill {token!r} already exists at {target}; use skill_edit to change it"
|
||||
if store.find(token) is not None:
|
||||
return f"error: skill {token!r} is already visible from another root; edit it there instead"
|
||||
return target, token
|
||||
|
||||
|
||||
def _bundled_targets(target: Path, files: dict[str, str], *, name: str) -> dict[Path, str] | str:
|
||||
"""Resolve every bundled file path before anything is written."""
|
||||
resolved: dict[Path, str] = {}
|
||||
for relative, content in files.items():
|
||||
path = resolve_inside(target, relative)
|
||||
if path is None:
|
||||
return f"error: bundled file {relative!r} is outside skill {name!r}"
|
||||
resolved[path] = content
|
||||
return resolved
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
||||
async def skill_create(
|
||||
name: str,
|
||||
description: str,
|
||||
body: str,
|
||||
*,
|
||||
files: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Create a new skill: a directory with a ``SKILL.md`` and optional files.
|
||||
|
||||
``name`` is the skill name (lowercase letters, digits, hyphens); the skill
|
||||
is written under the default write root (our own user skills directory
|
||||
unless ``[skills].paths`` puts an explicit one first). ``body`` is the
|
||||
instruction text, and ``files`` maps paths inside the skill to their content
|
||||
(scripts, references, templates). Writing needs a grant: the human is asked
|
||||
once per skill, or the whole feature is opened with ``[skills].allow_write``.
|
||||
"""
|
||||
store = _store_or_error()
|
||||
if isinstance(store, str):
|
||||
return store
|
||||
resolution = _create_target(store, name)
|
||||
if isinstance(resolution, str):
|
||||
return resolution
|
||||
target, token = resolution
|
||||
bundled = _bundled_targets(target, files or {}, name=token)
|
||||
if isinstance(bundled, str):
|
||||
return bundled
|
||||
|
||||
detail = f"create skill {token!r} at {target} (SKILL.md"
|
||||
detail += f" + {len(bundled)} file(s)" if bundled else ""
|
||||
detail += ")"
|
||||
denial = _authorize_write(target, target, action="create", detail=detail)
|
||||
if denial is not None:
|
||||
return denial
|
||||
|
||||
try:
|
||||
_write_file(target / SKILL_FILE, _render_skill_md(token, description, body))
|
||||
for path, content in bundled.items():
|
||||
_write_file(path, content)
|
||||
except OSError as exc:
|
||||
return f"error: failed to create skill {token!r}: {exc}"
|
||||
store.reload()
|
||||
return f"created skill {token!r} at {target}{_skill_notes(target / SKILL_FILE)}"
|
||||
|
||||
|
||||
def _edit_target(store: SkillStore, name: str, file: str) -> tuple[Skill, Path, str] | str:
|
||||
"""Look up the skill and the file to edit inside it."""
|
||||
skill = store.find(name)
|
||||
if skill is None:
|
||||
return _unknown_skill(name)
|
||||
target = resolve_skill_file(skill, file)
|
||||
if target is None:
|
||||
return f"error: file {file!r} is outside skill {skill.name!r}"
|
||||
return skill, target, file.strip() or SKILL_FILE
|
||||
|
||||
|
||||
def _edit_mode_error(*, old_string: str, new_string: str, append: str) -> str | None:
|
||||
"""Reject an edit request that names no form, or two at once."""
|
||||
if not (old_string or new_string or append):
|
||||
return "error: nothing to write; pass old_string + new_string, append, or new_string alone"
|
||||
if old_string and append:
|
||||
return "error: use either old_string + new_string or append, not both"
|
||||
return None
|
||||
|
||||
|
||||
def _apply_text_edit(
|
||||
existing: str | None,
|
||||
*,
|
||||
relative: str,
|
||||
skill_name: str,
|
||||
old_string: str,
|
||||
new_string: str,
|
||||
append: str,
|
||||
replace_all: bool,
|
||||
) -> tuple[str, str] | str:
|
||||
"""Compute the new file text; ``(text, detail)`` or an error string."""
|
||||
mode_error = _edit_mode_error(old_string=old_string, new_string=new_string, append=append)
|
||||
if mode_error is not None:
|
||||
return mode_error
|
||||
if old_string:
|
||||
text = existing or ""
|
||||
found = text.count(old_string) # non-overlapping, like str.replace
|
||||
if found == 0:
|
||||
return f"error: old_string not found in {relative} of skill {skill_name!r}"
|
||||
limit = found if replace_all else 1
|
||||
detail = f"{relative}: replaced {limit} of {found} occurrence(s)"
|
||||
return text.replace(old_string, new_string, limit), detail
|
||||
if append:
|
||||
if existing is None:
|
||||
return f"error: no such file in skill {skill_name!r}: {relative}"
|
||||
separator = "" if not existing or existing.endswith("\n") else "\n"
|
||||
detail = f"{relative}: appended {len(append)} characters"
|
||||
return f"{existing}{separator}{append}", detail
|
||||
return new_string, f"{relative}: rewrote {len(new_string)} characters"
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
||||
async def skill_edit(
|
||||
name: str,
|
||||
*,
|
||||
file: str = SKILL_FILE,
|
||||
old_string: str = "",
|
||||
new_string: str = "",
|
||||
append: str = "",
|
||||
replace_all: bool = False,
|
||||
) -> str:
|
||||
"""Change one file of an existing skill (``SKILL.md`` by default).
|
||||
|
||||
Three mutually exclusive forms:
|
||||
|
||||
- ``old_string`` + ``new_string``: literal replacement of the first match,
|
||||
or of every match with ``replace_all``;
|
||||
- ``append``: add text at the end of the file;
|
||||
- ``new_string`` alone: rewrite the whole file (a missing file is created,
|
||||
parent directories included).
|
||||
|
||||
Writing needs a grant for that skill (asked once, or standing via
|
||||
``[skills].allow_write``).
|
||||
"""
|
||||
store = _store_or_error()
|
||||
if isinstance(store, str):
|
||||
return store
|
||||
resolution = _edit_target(store, name, file)
|
||||
if isinstance(resolution, str):
|
||||
return resolution
|
||||
skill, target, relative = resolution
|
||||
existing = target.read_text(encoding="utf-8", errors="replace") if target.is_file() else None
|
||||
outcome = _apply_text_edit(
|
||||
existing,
|
||||
relative=relative,
|
||||
skill_name=skill.name,
|
||||
old_string=old_string,
|
||||
new_string=new_string,
|
||||
append=append,
|
||||
replace_all=replace_all,
|
||||
)
|
||||
if isinstance(outcome, str):
|
||||
return outcome
|
||||
updated, detail = outcome
|
||||
|
||||
denial = _authorize_write(skill.path, target, action="update", detail=f"{skill.name}: {detail}")
|
||||
if denial is not None:
|
||||
return denial
|
||||
try:
|
||||
_write_file(target, updated)
|
||||
except OSError as exc:
|
||||
return f"error: failed to write {relative} of skill {skill.name!r}: {exc}"
|
||||
store.reload()
|
||||
notes = _skill_notes(target) if target.name == SKILL_FILE else ""
|
||||
return f"updated skill {skill.name!r} ({detail}){notes}"
|
||||
|
||||
|
||||
SKILL_TOOLS = [
|
||||
skill_list,
|
||||
skill_read,
|
||||
skill_search,
|
||||
skill_create,
|
||||
skill_edit,
|
||||
]
|
||||
|
||||
# Fed to the catalog selection filter: ``[skills].enabled = false`` drops these
|
||||
# from the model-visible registry (definitions still exist in the catalog).
|
||||
SKILL_TOOL_NAMES = frozenset(definition.name for definition in SKILL_TOOLS)
|
||||
@@ -98,6 +98,10 @@ class WorkspacePolicy:
|
||||
config_allow: dict[Path, AccessMode] = field(default_factory=dict)
|
||||
# Process-scoped YOLO / --yes grants (resolved path → mode); never persisted.
|
||||
yolo_allow: dict[Path, AccessMode] = field(default_factory=dict)
|
||||
# Host-contributed read-only roots (skill directories): every path tool may
|
||||
# read them, nothing may write them. Not a grant, so they stay out of
|
||||
# ``/grants``; writes go through the owning tool's own confirm.
|
||||
static_read: list[Path] = field(default_factory=list)
|
||||
|
||||
|
||||
def _bound_instance() -> InstanceState | None:
|
||||
@@ -285,7 +289,7 @@ def _under_any_root(resolved: Path, instance: InstanceState, policy: WorkspacePo
|
||||
|
||||
|
||||
def granted_access_mode(resolved: Path) -> AccessMode | None:
|
||||
"""Highest config / session / process mode covering *resolved*, if any."""
|
||||
"""Highest config / session / process / static-read mode covering *resolved*."""
|
||||
policy = active_workspace_policy()
|
||||
session = _bound_session()
|
||||
buckets: list[Mapping[Path, AccessMode]] = [policy.config_allow, policy.yolo_allow]
|
||||
@@ -296,9 +300,36 @@ def granted_access_mode(resolved: Path) -> AccessMode | None:
|
||||
for root, granted in grants.items():
|
||||
if (resolved == root or resolved.is_relative_to(root)) and (best is None or granted > best):
|
||||
best = granted
|
||||
for root in policy.static_read:
|
||||
if (resolved == root or resolved.is_relative_to(root)) and (best is None or best < AccessMode.READ):
|
||||
best = AccessMode.READ
|
||||
return best
|
||||
|
||||
|
||||
def set_static_read_roots(
|
||||
roots: Sequence[Path | str],
|
||||
*,
|
||||
instance: InstanceState | None = None,
|
||||
) -> list[Path]:
|
||||
"""Install host-contributed read-only roots (resolved); returns them.
|
||||
|
||||
Replaces any previous list. Roots that are not existing directories are
|
||||
dropped: an absent skill directory holds nothing to read, and a missing
|
||||
entry must never turn into an implicit write permission later.
|
||||
"""
|
||||
inst = instance if instance is not None else require_bound_instance()
|
||||
resolved: list[Path] = []
|
||||
for root in roots:
|
||||
try:
|
||||
path = Path(root).expanduser().resolve()
|
||||
except OSError:
|
||||
continue
|
||||
if path.is_dir():
|
||||
resolved.append(path)
|
||||
inst.workspace.static_read[:] = resolved
|
||||
return resolved
|
||||
|
||||
|
||||
def _path_grant_covers(resolved: Path, required: AccessMode) -> bool:
|
||||
"""Whether a config / session / process grant covers *resolved*."""
|
||||
granted = granted_access_mode(resolved)
|
||||
|
||||
@@ -79,7 +79,7 @@ def test_parallel_tool_outputs_interleaved_after_calls() -> None:
|
||||
),
|
||||
AssistantFunctionToolCall(
|
||||
id="call_02_ABCD",
|
||||
function=AssistantFunctionTool(name="grep_files", arguments='{"pattern":"todo"}'),
|
||||
function=AssistantFunctionTool(name="regex_files", arguments='{"pattern":"todo"}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -70,6 +70,46 @@ async def test_registry_handler_error() -> None:
|
||||
assert "failed" in result
|
||||
|
||||
|
||||
async def test_registry_reports_missing_required_arguments() -> None:
|
||||
"""A missing required arg is explained with the accepted names, not a TypeError."""
|
||||
called: list[str] = []
|
||||
|
||||
@tool(register=False)
|
||||
def pick(question: str, options: list[str], *, allow_custom: bool = True) -> str:
|
||||
del allow_custom
|
||||
called.append(question)
|
||||
return options[0]
|
||||
|
||||
registry = ToolRegistry([pick])
|
||||
result = await registry.execute("pick", '{"question": "Which?"}')
|
||||
assert result == (
|
||||
"error: `pick` is missing required argument(s): options; it accepts: allow_custom, options, question"
|
||||
)
|
||||
assert called == [] # the handler never ran
|
||||
assert await registry.execute("pick", '{"question": "Which?", "options": ["a"]}') == "a"
|
||||
assert called == ["Which?"]
|
||||
|
||||
|
||||
async def test_registry_missing_arg_skips_confirm_hook() -> None:
|
||||
"""The human is not asked to confirm a call that can never run."""
|
||||
confirms: list[str] = []
|
||||
|
||||
def confirm(name: str, args: object, reason: str) -> bool:
|
||||
del args, reason
|
||||
confirms.append(name)
|
||||
return True
|
||||
|
||||
@tool(register=False)
|
||||
def write(path: str, content: str) -> str:
|
||||
del content
|
||||
return path
|
||||
|
||||
registry = ToolRegistry([write], danger=lambda name, args: "writes a file", on_confirm=confirm)
|
||||
result = await registry.execute("write", '{"path": "x"}')
|
||||
assert "missing required argument(s): content" in result
|
||||
assert confirms == []
|
||||
|
||||
|
||||
def test_tool_items() -> None:
|
||||
@tool(register=False)
|
||||
def f(x: int) -> int:
|
||||
|
||||
+440
-10
@@ -4,6 +4,9 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import ErrorEvent, ReasoningDeltaEvent, TextDeltaEvent, ToolCallEvent, ToolResultEvent
|
||||
from plyngent.cli.display import (
|
||||
_clear_streamed_lines,
|
||||
_line_count_for_clear,
|
||||
_pretty_line_for,
|
||||
get_markdown_enabled,
|
||||
markdown_render_available,
|
||||
print_markdown,
|
||||
@@ -65,7 +68,7 @@ async def test_flush_markdown_on_source_change(
|
||||
|
||||
call = AssistantFunctionToolCall(
|
||||
id="1",
|
||||
function=AssistantFunctionTool(name="vcs_diff", arguments='{"path": ""}'),
|
||||
function=AssistantFunctionTool(name="acme_ping", arguments='{"host": "example.com"}'),
|
||||
)
|
||||
await render_events(
|
||||
_aiter(
|
||||
@@ -183,15 +186,16 @@ async def test_pretty_todo_push(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
|
||||
|
||||
async def test_non_pretty_tool_keeps_old_style(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Tools with no pretty line (plugins, unknown names) keep the ``[tool]`` style."""
|
||||
call = ToolCallEvent(
|
||||
tool_call=AssistantFunctionToolCall(
|
||||
id="3",
|
||||
function=AssistantFunctionTool(name="vcs_diff", arguments='{"path": ""}'),
|
||||
function=AssistantFunctionTool(name="acme_ping", arguments='{"host": "example.com"}'),
|
||||
)
|
||||
)
|
||||
await render_events(_aiter([call, _result("diff --git a/x b/x")]))
|
||||
await render_events(_aiter([call, _result("pong")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "[tool] vcs_diff(" in out
|
||||
assert "[tool] acme_ping(" in out
|
||||
assert "[tool ok]" in out
|
||||
|
||||
|
||||
@@ -346,17 +350,17 @@ async def test_pretty_glob_truncated(capsys: pytest.CaptureFixture[str]) -> None
|
||||
assert "* Glob '*' in '.' (2 paths)" in out
|
||||
|
||||
|
||||
async def test_pretty_grep(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
async def test_pretty_regex_files(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
result = "src/a.py:3: x = 1\nsrc/a.py:9: x = 2\nsrc/b.py:1: y = 3\n"
|
||||
await render_events(_aiter([_pretty_call("grep_files", '{"pattern": "x"}'), _result(result)]))
|
||||
await render_events(_aiter([_pretty_call("regex_files", '{"pattern": "x"}'), _result(result)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Grep 'x' in '.' (3 matches in 2 files)" in out
|
||||
assert "* Search 'x' in '.' (3 matches in 2 files)" in out
|
||||
|
||||
|
||||
async def test_pretty_grep_no_matches(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(_aiter([_pretty_call("grep_files", '{"pattern": "zzz"}'), _result("(no matches)")]))
|
||||
async def test_pretty_regex_files_no_matches(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(_aiter([_pretty_call("regex_files", '{"pattern": "zzz"}'), _result("(no matches)")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Grep 'zzz' in '.' (no matches)" in out
|
||||
assert "* Search 'zzz' in '.' (no matches)" in out
|
||||
|
||||
|
||||
async def test_pretty_run_argv_success(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
@@ -553,6 +557,13 @@ async def test_pretty_wait_disturbed(capsys: pytest.CaptureFixture[str]) -> None
|
||||
assert "* Wait (disturbed)" in out
|
||||
|
||||
|
||||
async def test_pretty_wait_cancelled(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Ctrl+C during the wait prompt reads as cancelled, not disturbed."""
|
||||
await render_events(_aiter([_pretty_call("wait", '{"duration": 30}'), _result("cancelled by user")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Wait (cancelled)" in out
|
||||
|
||||
|
||||
async def test_pretty_wait_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
content = "error: duration must not be negative"
|
||||
await render_events(_aiter([_pretty_call("wait", '{"duration": -1}'), _result(content)]))
|
||||
@@ -560,6 +571,338 @@ async def test_pretty_wait_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert "* Wait (error: duration must not be negative)" in out
|
||||
|
||||
|
||||
async def test_pretty_pty_open_and_read(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
opened = "session_id=3\nalive=true\nexit_code=\ncmd=ls -la"
|
||||
await render_events(_aiter([_pretty_call("open_pty", '{"command": ["ls", "-la"]}'), _result(opened)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Open $ ls -la (session 3)" in out
|
||||
|
||||
read = (
|
||||
"session_id=3\nalive=true\nexit_code=\nmatched=false\ntruncated=false\n"
|
||||
"budget_exhausted=false\n--- data ---\nhello\n"
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("read_pty", '{"session_id": 3}'), _result(read)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Read 3 (alive, 1 line)" in out
|
||||
|
||||
|
||||
async def test_pretty_pty_read_exited_and_matched(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""An exited session reports the code; ``until`` reports that it matched."""
|
||||
exited = (
|
||||
"session_id=3\nalive=false\nexit_code=0\nmatched=false\ntruncated=false\n"
|
||||
"budget_exhausted=false\n--- data ---\nbye\n"
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("read_pty", '{"session_id": 3}'), _result(exited)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Read 3 (exited 0)" in out
|
||||
|
||||
matched = (
|
||||
"session_id=3\nalive=true\nexit_code=\nmatched=true\ntruncated=false\n"
|
||||
"budget_exhausted=false\n--- data ---\nprompt\n$ \n"
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("read_pty", '{"session_id": 3, "until": "$ "}'), _result(matched)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Read 3 (matched, 2 lines)" in out
|
||||
|
||||
|
||||
async def test_pretty_pty_read_empty_and_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
empty = (
|
||||
"session_id=3\nalive=true\nexit_code=\nmatched=false\ntruncated=false\nbudget_exhausted=false\n--- data ---\n"
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("read_pty", '{"session_id": 3}'), _result(empty)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Read 3 (alive, no output)" in out
|
||||
|
||||
await render_events(
|
||||
_aiter([_pretty_call("read_pty", '{"session_id": 9}'), _result("error: unknown PTY session 9")])
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Read 9 (error: unknown PTY session 9)" in out
|
||||
|
||||
|
||||
async def test_pretty_pty_write_and_keys(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
status = "session_id=3\nalive=true\nexit_code=\nwrote=5"
|
||||
await render_events(_aiter([_pretty_call("write_pty", '{"session_id": 3, "data": "ls -la"}'), _result(status)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Write 3 (5 chars)" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("write_pty_keys", '{"session_id": 3, "data": "ctrl+c"}'),
|
||||
_result("session_id=3\nalive=true\nexit_code=\nwrote=1"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Keys 3 (1 char)" in out
|
||||
|
||||
|
||||
async def test_pretty_ask_into_pty_never_shows_the_answer(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""The result only says the answer was written; the payload stays local."""
|
||||
status = "session_id=3\nalive=true\nexit_code=\nwrote=true\nsecret=true\nsubmit=true\nsource=human"
|
||||
await render_events(
|
||||
_aiter([_pretty_call("ask_into_pty", '{"session_id": 3, "message": "sudo password"}'), _result(status)])
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Ask 3 (answer sent, secret)" in out
|
||||
assert "wrote=true" not in out
|
||||
|
||||
|
||||
async def test_pretty_close_pty(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
closed = "session_id=3\nclosed=true\nalive=false\nexit_code=0\nmessage=closed"
|
||||
await render_events(_aiter([_pretty_call("close_pty", '{"session_id": 3}'), _result(closed)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Close 3 (closed)" in out
|
||||
|
||||
already = "session_id=3\nclosed=false\nalive=false\nexit_code=0\nmessage=already closed"
|
||||
await render_events(_aiter([_pretty_call("close_pty", '{"session_id": 3}'), _result(already)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* PTY Close 3 (already closed)" in out
|
||||
|
||||
|
||||
async def test_pretty_ask_user_line_and_choice(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(_aiter([_pretty_call("ask_user_line", '{"question": "Which port?"}'), _result("8080")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Ask 'Which port?' (answered)" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("ask_user_choice", '{"question": "Pick one", "options": ["alpha", "beta"]}'),
|
||||
_result("beta"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Ask Choose 'Pick one' (beta)" in out
|
||||
|
||||
|
||||
async def test_pretty_ask_user_form_counts_answers(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("ask_user_form", '{"title": "Setup", "fields": [{"name": "port", "prompt": "Port?"}]}'),
|
||||
_result('{"port": 8080, "host": "localhost"}'),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Ask Form 'Setup' (2 answers)" in out
|
||||
|
||||
|
||||
async def test_pretty_ask_user_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("ask_user_line", '{"question": "Port?"}'),
|
||||
_result("error: interactive prompts are unavailable (stdin is not a TTY)"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Ask 'Port?' (error: interactive prompts are unavailable (stdin is not a TTY))" in out
|
||||
|
||||
|
||||
async def test_pretty_request_directory_access(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("request_directory_access", '{"path": "/data", "mode": "write"}'),
|
||||
_result("access granted: /data (write, session)\nnote: the path denylist still applies"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Access '/data' (write) (granted)" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("request_directory_access", '{"path": "/data"}'),
|
||||
_result("already accessible: /data is inside the workspace"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Access '/data' (read) (already accessible)" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("request_directory_access", '{"path": "/root"}'),
|
||||
_result("error: access to /root denied (declined or timed out after 30s)"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "(error: access to /root denied (declined or timed out after 30s))" in out
|
||||
|
||||
|
||||
async def test_pretty_new_temporary_workspace(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
content = "temporary_workspace=/tmp/plyngent-ws-abc\nnote: project workspace unchanged"
|
||||
await render_events(_aiter([_pretty_call("new_temporary_workspace", '{"prefix": "ws"}'), _result(content)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Temp Dir (/tmp/plyngent-ws-abc)" in out
|
||||
|
||||
|
||||
async def test_pretty_get_truncated(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(_aiter([_pretty_call("get_truncated", '{"token": "abc"}'), _result("L81-160\nmore\n")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Resume Truncated L81-160 (done)" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("get_truncated", '{"token": "abc"}'),
|
||||
_result("error: truncate token expired (no longer in memory); re-run the tool"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "(error: truncate token expired (no longer in memory); re-run the tool)" in out
|
||||
|
||||
|
||||
async def test_pretty_vcs_kind_branch_and_diff(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(_aiter([_pretty_call("vcs_kind", "{}"), _result("git")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* VCS Kind (git)" in out
|
||||
|
||||
await render_events(_aiter([_pretty_call("vcs_branch", "{}"), _result("main")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* VCS Branch (main)" in out
|
||||
|
||||
diff = (
|
||||
"diff --git a/x.py b/x.py\nindex 111..222 100644\n--- a/x.py\n+++ b/x.py\n@@ -1 +1,2 @@\n-old\n+new\n+extra\n"
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("vcs_diff", '{"path": ""}'), _result(diff)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* VCS Diff (1 file, +2/-1)" in out
|
||||
|
||||
await render_events(_aiter([_pretty_call("vcs_diff", '{"staged": true}'), _result("(no diff)")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* VCS Diff (staged) (no diff)" in out
|
||||
|
||||
|
||||
async def test_pretty_todo_list_pop_and_clear(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
stack = " [ ] a1: T1\n [x] a2: T2"
|
||||
await render_events(_aiter([_pretty_call("todo_list", "{}"), _result(stack)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Todo List:" in out
|
||||
assert " [ ] a1: T1" in out
|
||||
|
||||
await render_events(_aiter([_pretty_call("todo_pop", "{}"), _result(f"popped TOP group (a1:T1)\n{stack}")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Todo Pop:" in out
|
||||
|
||||
await render_events(_aiter([_pretty_call("todo_clear", "{}"), _result("cleared 3 item(s)")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Todo Clear:" in out
|
||||
assert "cleared 3 item(s)" in out
|
||||
|
||||
|
||||
async def test_pretty_mcp_tools_use_one_generic_line(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Namespaced MCP tools render as ``* MCP <server>:<tool> ``."""
|
||||
await render_events(_aiter([_pretty_call("mcp__docs__search", '{"query": "x"}'), _result("found 1 page")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* MCP docs:search (done)" in out
|
||||
assert "[tool]" not in out
|
||||
|
||||
await render_events(_aiter([_pretty_call("mcp__docs__search", '{"query": "x"}'), _result("error: server died")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* MCP docs:search (error: server died)" in out
|
||||
|
||||
|
||||
def test_every_builtin_tool_has_a_pretty_line() -> None:
|
||||
"""A new builtin tool must ship a renderer, or it silently loses the syntax."""
|
||||
from plyngent.tools.catalog import default_tool_definitions
|
||||
|
||||
missing = [d.name for d in default_tool_definitions() if _pretty_line_for(d.name) is None]
|
||||
assert missing == []
|
||||
|
||||
|
||||
async def test_pretty_skill_tools(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(
|
||||
_aiter(
|
||||
[_pretty_call("skill_list", "{}"), _result("skills: 3\npdf-processing [plyngent-user] /x — Extract PDFs")]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Skills (3 skills)" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("skill_read", '{"name": "pdf-processing"}'),
|
||||
_result("L1-40\n# PDF processing\n"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Skill 'pdf-processing' L1-40 (done)" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("skill_read", '{"name": "pdf-processing", "file": "scripts/run.sh"}'),
|
||||
_result("L1-3\n#!/bin/sh\n"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Skill 'pdf-processing' scripts/run.sh L1-3 (done)" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("skill_search", '{"pattern": "pdftotext"}'),
|
||||
_result("alpha/SKILL.md:3: Use pdftotext.\nbeta/notes.md:1: pdftotext again"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Skill Search 'pdftotext' (2 matches in 2 skills)" in out
|
||||
|
||||
await render_events(_aiter([_pretty_call("skill_search", '{"pattern": "nope"}'), _result("(no matches)")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Skill Search 'nope' (no matches)" in out
|
||||
|
||||
|
||||
async def test_pretty_skill_create_and_edit(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("skill_create", '{"name": "acme", "description": "d", "body": "b"}'),
|
||||
_result("created skill 'acme' at /skills/acme\nnotes: SKILL.md has no frontmatter description"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Skill Create 'acme' (created) — SKILL.md has no frontmatter description" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("skill_edit", '{"name": "acme"}'),
|
||||
_result("updated skill 'acme' (SKILL.md: replaced 1 of 1 occurrence(s))"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Skill Edit 'acme' (SKILL.md: replaced 1 of 1 occurrence(s))" in out
|
||||
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("skill_edit", '{"name": "acme", "file": "scripts/run.sh"}'),
|
||||
_result("error: skill write to /skills/acme denied (declined or timed out after 30s)"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Skill Edit 'acme' scripts/run.sh (error: skill write to /skills/acme denied" in out
|
||||
|
||||
|
||||
async def test_pretty_prefix_shown_while_call_runs(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
@@ -630,6 +973,93 @@ async def test_pretty_parallel_batch_erases_open_prefix(
|
||||
assert "* Read 'b.txt' L1-9 (done)\n" in out
|
||||
|
||||
|
||||
async def test_pretty_parallel_batch_keeps_the_line_above_the_prefix(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Regression: erasing the open prefix must not eat the line above it.
|
||||
|
||||
Reasoning streams without a trailing newline, so the prefix line sits right
|
||||
below the reasoning text; the erase used to clear one row too many and wiped
|
||||
that reasoning line out of the screen.
|
||||
"""
|
||||
monkeypatch.setattr("plyngent.cli.display.interactive_terminal", lambda: True)
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
ReasoningDeltaEvent(content="think"),
|
||||
_pretty_call("read_file", '{"path": "a.txt"}'),
|
||||
_pretty_call("read_file", '{"path": "b.txt"}'),
|
||||
_result("L1-4\none\n"),
|
||||
_result("L1-9\ntwo\n"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert out == (
|
||||
"\nreasoning:\nthink"
|
||||
"\n* Read 'a.txt' "
|
||||
"\r\x1b[2K\x1b[1A"
|
||||
"\n* Read 'a.txt' L1-4 (done)\n"
|
||||
"\n* Read 'b.txt' L1-9 (done)\n"
|
||||
"\n\n"
|
||||
)
|
||||
|
||||
|
||||
def test_clear_streamed_lines_clears_exactly_the_given_rows(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Two rows means two clears; the cursor lands on the topmost cleared row."""
|
||||
_clear_streamed_lines(2)
|
||||
assert capsys.readouterr().out == "\r\x1b[2K\x1b[1A\r\x1b[2K"
|
||||
|
||||
|
||||
def test_clear_streamed_lines_resume_above_steps_back_one_row(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""``resume_above`` clears the cursor row only, then steps above it."""
|
||||
_clear_streamed_lines(1, resume_above=True)
|
||||
assert capsys.readouterr().out == "\r\x1b[2K\x1b[1A"
|
||||
|
||||
|
||||
def test_clear_streamed_lines_ignores_non_positive_counts(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
_clear_streamed_lines(0)
|
||||
_clear_streamed_lines(-1)
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_line_count_for_clear_counts_wrapped_rows(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A streamed line wider than the terminal owns more than one row."""
|
||||
monkeypatch.setenv("COLUMNS", "20")
|
||||
assert _line_count_for_clear("assistant:", "x" * 20) == 2 # label + full row
|
||||
assert _line_count_for_clear("assistant:", "x" * 21) == 3 # label + two rows
|
||||
assert _line_count_for_clear("assistant:", "a\nb") == 3
|
||||
assert _line_count_for_clear("", "") == 0
|
||||
|
||||
|
||||
async def test_flush_markdown_erases_wrapped_assistant_rows(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A wrapped assistant segment is fully erased before the markdown re-render."""
|
||||
monkeypatch.setattr("plyngent.cli.display.interactive_terminal", lambda: True)
|
||||
monkeypatch.setattr("plyngent.cli.display.markdown_render_available", lambda: True)
|
||||
monkeypatch.setenv("COLUMNS", "20")
|
||||
set_markdown_enabled(True)
|
||||
try:
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
TextDeltaEvent(content="x" * 21),
|
||||
_pretty_call("read_file", '{"path": "a.txt"}'),
|
||||
_result("L1-4\none\n"),
|
||||
]
|
||||
)
|
||||
)
|
||||
finally:
|
||||
set_markdown_enabled(True)
|
||||
out = capsys.readouterr().out
|
||||
# label + two wrapped body rows + the blank separator above the label.
|
||||
assert out.count("\x1b[2K") == 4
|
||||
assert out.count("\x1b[1A") == 3
|
||||
|
||||
|
||||
async def test_pretty_prefix_closed_before_error_line(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
"""``/history``: turn grouping, display numbering, verbose levels, addressing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
import tomlkit
|
||||
|
||||
from plyngent.agent import ChatAgent
|
||||
from plyngent.cli.slash import complete_slash_args, handle_slash
|
||||
from plyngent.cli.state import ReplState
|
||||
from plyngent.cli.transcript import build_transcript, one_line
|
||||
from plyngent.config.models import DatabaseConfig, OpenAIProvider
|
||||
from plyngent.config.store import ConfigStore
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantChatMessage,
|
||||
AssistantFunctionTool,
|
||||
AssistantFunctionToolCall,
|
||||
DeveloperChatMessage,
|
||||
SystemChatMessage,
|
||||
ToolChatMessage,
|
||||
UserChatMessage,
|
||||
)
|
||||
from plyngent.memory import MemoryStore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||
|
||||
|
||||
class StubClient:
|
||||
"""No model calls happen while rendering history."""
|
||||
|
||||
async def chat_completions(self, param: object, *, stream: bool = False) -> object:
|
||||
del param, stream
|
||||
raise AssertionError("history must not call the model")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def state(tmp_path: Path) -> AsyncIterator[ReplState]:
|
||||
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=False,
|
||||
)
|
||||
st.client = StubClient()
|
||||
st.agent = ChatAgent(st.client, model=st.model, memory=st.memory, session_id=None)
|
||||
await st.new_session("t")
|
||||
yield st
|
||||
await memory.close()
|
||||
|
||||
|
||||
def _tool_call(message_id: str, name: str, args: str = "{}") -> AssistantChatMessage:
|
||||
call = AssistantFunctionToolCall(id=message_id, function=AssistantFunctionTool(name=name, arguments=args))
|
||||
return AssistantChatMessage(content="", tool_calls=[call])
|
||||
|
||||
|
||||
def _two_turns(*, system: bool = False) -> list[AnyChatMessage]:
|
||||
"""Two settled turns; turn 1 has two tool rounds, turn 2 one."""
|
||||
messages: list[AnyChatMessage] = []
|
||||
if system:
|
||||
messages.append(SystemChatMessage(content="You are plyngent. " + "rules. " * 30))
|
||||
messages += [
|
||||
UserChatMessage(content="read src/a.py and fix the typo"),
|
||||
_tool_call("c1", "read_file", '{"path": "src/a.py"}'),
|
||||
ToolChatMessage(tool_call_id="c1", content="L1-30\n" + "print('hi')\n" * 25 + "print('bye')\n"),
|
||||
DeveloperChatMessage(content="[DIRECTIVE CHECKPOINT band=1 tokens≈100000 source=estimate]\nplaybook…"),
|
||||
_tool_call("c2", "edit_replace", '{"path": "src/a.py"}'),
|
||||
ToolChatMessage(tool_call_id="c2", content="replaced 1 occurrence"),
|
||||
AssistantChatMessage(content="Fixed the typo in `src/a.py`.\n\n- line 12 changed"),
|
||||
UserChatMessage(content="now run the tests"),
|
||||
_tool_call("c3", "run_argv", '{"argv": ["pytest", "-q"]}'),
|
||||
ToolChatMessage(tool_call_id="c3", content="12 passed in 3.2s"),
|
||||
AssistantChatMessage(content="Tests pass: **12 passed** in 3.2s."),
|
||||
]
|
||||
return messages
|
||||
|
||||
|
||||
async def test_default_shows_the_last_turns_two_ends(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert await handle_slash(state, "/history") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "turns=2 messages=11 showing=last 1 turn(s) mode=mixed" in out
|
||||
assert "turn 2 (msgs 8-11, rounds=1)" in out
|
||||
# Both ends are printed in full, not as one-line previews.
|
||||
assert "8. user:\nnow run the tests" in out
|
||||
assert "11. assistant:" in out
|
||||
assert "Tests pass: **12 passed** in 3.2s." in out
|
||||
assert "tool(c3)" not in out
|
||||
assert "turn 1" not in out
|
||||
|
||||
|
||||
async def test_verbose_expands_every_row_of_the_turn(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert await handle_slash(state, "/history -v 1") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "turn 1 (msgs 1-7, rounds=2)" in out
|
||||
# The ends stay full (the human's own words, the model's answer) …
|
||||
assert "1. user:\nread src/a.py and fix the typo" in out
|
||||
assert "7. assistant:" in out
|
||||
assert "- line 12 changed" in out
|
||||
# … while the rounds in between are one-liners.
|
||||
assert "2. assistant: tool_calls=[read_file]" in out
|
||||
assert "3. tool(c1): L1-30 (+26 lines)" in out
|
||||
assert "4. developer: [DIRECTIVE CHECKPOINT band=1" in out
|
||||
assert "6. tool(c2): replaced 1 occurrence" in out
|
||||
assert "print('bye')" not in out
|
||||
|
||||
|
||||
async def test_double_verbose_prints_full_bodies(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state.agent.messages = _two_turns(system=True)
|
||||
assert await handle_slash(state, "/history -vv 1") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "mode=full" in out
|
||||
# A tool result past the 200-char preview window is printed in full.
|
||||
assert "3. tool(c1):" in out
|
||||
assert "print('bye')" in out
|
||||
# -vv is also what surfaces the local-only system prompt.
|
||||
assert "local. system:" in out
|
||||
assert "You are plyngent." in out
|
||||
|
||||
|
||||
async def test_reasoning_prints_before_the_answer(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A reasoning trace leads the answer it produced, as it does live."""
|
||||
state.agent.messages = [
|
||||
UserChatMessage(content="why is the sky blue?"),
|
||||
AssistantChatMessage(content="Rayleigh scattering.", reasoning_content="Light scatters off air."),
|
||||
]
|
||||
assert await handle_slash(state, "/history") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "2. assistant:" in out
|
||||
assert "reasoning:\nLight scatters off air." in out
|
||||
assert out.index("reasoning:") < out.index("Rayleigh scattering.")
|
||||
assert out.index("2. assistant:") < out.index("reasoning:")
|
||||
|
||||
|
||||
async def test_reasoning_is_absent_without_one(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state.agent.messages = [UserChatMessage(content="hi"), AssistantChatMessage(content="hello")]
|
||||
assert await handle_slash(state, "/history -vv") is True
|
||||
assert "reasoning:" not in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_preview_never_prints_reasoning(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state.agent.messages = [
|
||||
UserChatMessage(content="why?"),
|
||||
AssistantChatMessage(content="Because.", reasoning_content="Because the docs say so."),
|
||||
]
|
||||
assert await handle_slash(state, "/history --preview") is True
|
||||
assert "Because the docs say so." not in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_verbose_keeps_local_rows_out_of_the_way(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state.agent.messages = _two_turns(system=True)
|
||||
assert await handle_slash(state, "/history -v 1") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "local. system:" not in out
|
||||
# Numbering is over conversation rows only, so the user row is #1.
|
||||
assert "1. user:\nread src/a.py and fix the typo" in out
|
||||
|
||||
|
||||
async def test_long_user_message_is_not_collapsed(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Regression: both ends of a turn print in full, however long they are."""
|
||||
body = "please review this: " + "x" * 400
|
||||
state.agent.messages = [UserChatMessage(content=body), AssistantChatMessage(content="ok")]
|
||||
assert await handle_slash(state, "/history") is True
|
||||
out = capsys.readouterr().out
|
||||
assert body in out
|
||||
assert "1. user:\n" in out
|
||||
assert "…" not in out.split("2. assistant:")[0]
|
||||
|
||||
|
||||
async def test_preview_collapses_every_row(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert await handle_slash(state, "/history last 2 --preview") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "showing=last 2 turn(s) mode=preview" in out
|
||||
assert "7. assistant: Fixed the typo in `src/a.py`." in out
|
||||
assert "11. assistant: Tests pass: **12 passed** in 3.2s." in out
|
||||
|
||||
|
||||
async def test_last_n_turns(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert await handle_slash(state, "/history last 2") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "turn 1 (msgs 1-7, rounds=2)" in out
|
||||
assert "turn 2 (msgs 8-11, rounds=1)" in out
|
||||
|
||||
|
||||
async def test_turn_out_of_range(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert await handle_slash(state, "/history 9") is True
|
||||
assert "error: no turn 9 (turns 1-2)" in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_message_number_prints_one_row_in_full(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert await handle_slash(state, "/history --message 3") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "showing=message 3 mode=full" in out
|
||||
assert "3. tool(c1):" in out
|
||||
assert "print('bye')" in out
|
||||
assert "(turn 1; /history 1 -v to see it whole)" in out
|
||||
|
||||
|
||||
async def test_message_number_preview(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert await handle_slash(state, "/history --message 3 --preview") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "showing=message 3 mode=preview" in out
|
||||
assert "3. tool(c1): L1-30 (+26 lines)" in out
|
||||
|
||||
|
||||
async def test_message_out_of_range(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert await handle_slash(state, "/history --message 99") is True
|
||||
assert "error: no message 99 (messages 1-11)" in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("command", "message"),
|
||||
[
|
||||
("/history 1 2", "a count applies to 'last'"),
|
||||
("/history --message 3 -v", "-v/-vv apply to turns"),
|
||||
("/history -vv --preview", "use only one of -vv / --preview"),
|
||||
("/history -vvv", "use at most -vv"),
|
||||
],
|
||||
)
|
||||
async def test_usage_errors(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
command: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert await handle_slash(state, command) is True # the REPL keeps running
|
||||
assert message in capsys.readouterr().err
|
||||
|
||||
|
||||
async def test_incomplete_turn_is_marked(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A retryable turn is flagged; no duplicate ``(pending retry)`` line."""
|
||||
state.agent.messages = [
|
||||
*_two_turns(),
|
||||
UserChatMessage(content="also update the changelog"),
|
||||
_tool_call("c4", "read_file", '{"path": "CHANGELOG.md"}'),
|
||||
ToolChatMessage(tool_call_id="c4", content="L1-4\n# Changelog"),
|
||||
]
|
||||
assert state.agent.pending_retry_text == "also update the changelog"
|
||||
assert await handle_slash(state, "/history") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "turn 3 (msgs 12-14, rounds=1) [incomplete — /retry continues]" in out
|
||||
assert "pending retry" not in out
|
||||
|
||||
|
||||
async def test_no_turns_yet_shows_the_prelude(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A compacted session starts with a seed assistant row, no user turn."""
|
||||
state.agent.messages = [
|
||||
SystemChatMessage(content="You are plyngent."),
|
||||
AssistantChatMessage(content="Summary of session 4: …"),
|
||||
]
|
||||
assert await handle_slash(state, "/history") is True
|
||||
assert "(no turns yet in this session)" in capsys.readouterr().out
|
||||
assert await handle_slash(state, "/history -v") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "1. assistant: Summary of session 4: …" in out
|
||||
assert "local system" not in out.replace(".", " ")
|
||||
|
||||
|
||||
async def test_empty_history(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
state.agent.messages = []
|
||||
assert await handle_slash(state, "/history") is True
|
||||
assert "(no messages in this session)" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_turn_and_row_numbering() -> None:
|
||||
messages = _two_turns()
|
||||
view = build_transcript(messages)
|
||||
assert len(view.turns) == 2
|
||||
assert view.turns[0].ordinal == 1
|
||||
assert view.messages == 11
|
||||
assert view.turn(2) is not None
|
||||
assert view.turn(3) is None
|
||||
row = view.row(11)
|
||||
assert row is not None
|
||||
assert view.turn_of(11) is not None
|
||||
assert view.row(12) is None
|
||||
|
||||
|
||||
def test_one_line_preview() -> None:
|
||||
assert one_line(None) == ""
|
||||
assert one_line("single") == "single"
|
||||
assert one_line("first\nsecond\nthird") == "first (+2 lines)"
|
||||
assert one_line("x" * 250).endswith("…")
|
||||
assert len(one_line("x" * 250)) == 201
|
||||
|
||||
|
||||
def test_row_prefix_is_styled_once() -> None:
|
||||
"""Rows style their own prefix; renderers must not wrap it a second time."""
|
||||
view = build_transcript(_two_turns())
|
||||
row = view.row(1)
|
||||
assert row is not None
|
||||
prefix = row.prefix()
|
||||
assert prefix.count("\x1b[32m") == 1 # green user, exactly one style open
|
||||
assert prefix.startswith("\x1b[32m")
|
||||
assert "1. user:" in prefix
|
||||
local = build_transcript([SystemChatMessage(content="sys prompt")]).prelude[0]
|
||||
assert "local. system:" in local.prefix()
|
||||
|
||||
|
||||
async def test_slash_completion_offers_last_and_verbose(
|
||||
state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state.agent.messages = _two_turns()
|
||||
assert complete_slash_args(state, "/history", "la") == ["last"]
|
||||
assert "--verbose" in complete_slash_args(state, "/history", "--v")
|
||||
assert "2" in complete_slash_args(state, "/history", "2")
|
||||
capsys.readouterr()
|
||||
+246
-116
@@ -1,124 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.cli.interrupt import (
|
||||
allow_task_cancel,
|
||||
install_keyboard_interrupt_sigint,
|
||||
install_sigint_router,
|
||||
off_loop_prompt,
|
||||
pause_task_cancel_for_prompt,
|
||||
raise_keyboard_interrupt_sigint,
|
||||
run_in_prompt_thread,
|
||||
set_sigint_reinstall,
|
||||
sigint_cancels,
|
||||
sigint_routed,
|
||||
uninstall_sigint_router,
|
||||
)
|
||||
from plyngent.cli.limits import prompt_continue_limit
|
||||
from plyngent.cli.retry import run_cancellable, sleep_cancellable
|
||||
from plyngent.prompting import (
|
||||
PromptCancelledError,
|
||||
configure_prompting,
|
||||
prompt_cancelled,
|
||||
run_cancellable_prompt_async,
|
||||
temporary_backend,
|
||||
)
|
||||
from plyngent.cli.limits import prompt_continue_limit, prompt_continue_limit_async
|
||||
from plyngent.cli.retry import run_cancellable
|
||||
from plyngent.prompting import temporary_backend
|
||||
from tests.test_prompting import ScriptedBackend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest as pytest_module
|
||||
|
||||
|
||||
def test_pause_task_cancel_for_prompt() -> None:
|
||||
assert allow_task_cancel() is True
|
||||
with pause_task_cancel_for_prompt():
|
||||
assert allow_task_cancel() is False
|
||||
assert allow_task_cancel() is True
|
||||
class FakeTarget:
|
||||
"""Minimal ``InterruptTarget`` recording its cancels."""
|
||||
|
||||
def __init__(self, log: list[str], name: str = "cancel") -> None:
|
||||
self.log = log
|
||||
self.name = name
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.log.append(self.name)
|
||||
|
||||
|
||||
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:
|
||||
backend = ScriptedBackend(["y"])
|
||||
|
||||
def _read_line(prompt: str, *, default: str | None = None, completions: object = None) -> str:
|
||||
del prompt, default, completions
|
||||
assert allow_task_cancel() is False
|
||||
return "y"
|
||||
|
||||
monkeypatch.setattr(backend, "read_line", _read_line)
|
||||
with temporary_backend(backend):
|
||||
assert prompt_continue_limit("too many rounds") is True
|
||||
|
||||
|
||||
async def test_run_in_prompt_thread_pauses_cancel() -> None:
|
||||
"""Cancel is paused on the main thread for the whole to_thread call."""
|
||||
assert allow_task_cancel() is True
|
||||
|
||||
def work() -> str:
|
||||
return "ok"
|
||||
|
||||
result = await run_in_prompt_thread(work)
|
||||
assert result == "ok"
|
||||
assert allow_task_cancel() is True
|
||||
|
||||
|
||||
async def test_prompt_continue_limit_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
del monkeypatch
|
||||
with temporary_backend(ScriptedBackend(["y"])):
|
||||
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()
|
||||
@pytest.fixture
|
||||
async def router() -> AsyncIterator[None]:
|
||||
"""Install the session SIGINT router; restore the CLI handler afterwards."""
|
||||
install_keyboard_interrupt_sigint()
|
||||
_ = install_sigint_router()
|
||||
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")
|
||||
yield
|
||||
finally:
|
||||
uninstall_sigint_router()
|
||||
signal.signal(signal.SIGINT, signal.default_int_handler)
|
||||
|
||||
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
|
||||
async def _deliver_sigint() -> None:
|
||||
"""Send SIGINT to this process and let the loop run its signal callback.
|
||||
|
||||
asyncio's signal wakeup is only noticed while the loop parks in its
|
||||
selector, so a real (short) wait is needed — ``sleep(0)`` never parks and
|
||||
the callback would not run at all.
|
||||
"""
|
||||
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)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
def test_install_keyboard_interrupt_sigint() -> None:
|
||||
@@ -148,8 +97,6 @@ def test_asyncio_run_keeps_keyboard_interrupt_sigint() -> None:
|
||||
KeyboardInterrupt, which also cancelled ``memory.close()`` on a Ctrl+D exit.
|
||||
With a non-default handler installed first, Runner leaves SIGINT alone.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
install_keyboard_interrupt_sigint()
|
||||
try:
|
||||
|
||||
@@ -162,28 +109,211 @@ def test_asyncio_run_keeps_keyboard_interrupt_sigint() -> None:
|
||||
signal.signal(signal.SIGINT, signal.default_int_handler)
|
||||
|
||||
|
||||
async def test_run_cancellable_restores_previous_sigint() -> None:
|
||||
"""Regression: after a turn SIGINT is restored, not left as SIG_DFL.
|
||||
async def test_sigint_cancels_registered_target(router: None) -> None:
|
||||
"""SIGINT cancels the target registered around the awaited turn task."""
|
||||
del router
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def hang() -> None:
|
||||
try:
|
||||
await asyncio.sleep(3600)
|
||||
except asyncio.CancelledError:
|
||||
cancelled.set()
|
||||
raise
|
||||
|
||||
task = asyncio.create_task(hang())
|
||||
await asyncio.sleep(0)
|
||||
with sigint_cancels(task):
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
# Waiting on the effect (not on a fixed sleep) keeps this deterministic.
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await asyncio.wait_for(task, timeout=5)
|
||||
assert cancelled.is_set()
|
||||
|
||||
|
||||
async def test_sigint_while_loop_is_idle_does_not_escape(router: None) -> None:
|
||||
"""Regression: a stray Ctrl+C while the loop is parked in its selector.
|
||||
|
||||
The interrupt used to be raised in the loop's own frame (not inside any
|
||||
coroutine), escaping ``run_until_complete``: the Runner cancelled the main
|
||||
task and the REPL was left half-shut-down, so the next Ctrl+C killed the
|
||||
process instead of returning to the prompt.
|
||||
"""
|
||||
del router
|
||||
|
||||
def _send() -> None:
|
||||
time.sleep(0.05)
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
|
||||
sender = threading.Thread(target=_send)
|
||||
sender.start()
|
||||
try:
|
||||
await asyncio.sleep(0.2) # parked in the selector when SIGINT lands
|
||||
finally:
|
||||
sender.join()
|
||||
task = asyncio.current_task()
|
||||
assert task is not None
|
||||
assert task.cancelling() == 0
|
||||
assert not task.cancelled()
|
||||
|
||||
|
||||
async def test_sigint_cancels_innermost_target_only(router: None) -> None:
|
||||
"""Nested targets: the innermost (the active turn or prompt) wins."""
|
||||
del router
|
||||
outer: list[str] = []
|
||||
inner: list[str] = []
|
||||
with sigint_cancels(FakeTarget(outer, "outer")):
|
||||
await _deliver_sigint()
|
||||
assert outer == ["outer"]
|
||||
with sigint_cancels(FakeTarget(inner, "inner")):
|
||||
await _deliver_sigint()
|
||||
assert inner == ["inner"]
|
||||
assert outer == ["outer"] # the nested signal never reached it
|
||||
|
||||
|
||||
async def test_sigint_target_stops_after_context_exit(router: None) -> None:
|
||||
"""A popped target no longer sees SIGINT (and an empty stack ignores it)."""
|
||||
del router
|
||||
calls: list[str] = []
|
||||
with sigint_cancels(FakeTarget(calls)):
|
||||
await _deliver_sigint()
|
||||
assert calls == ["cancel"]
|
||||
await _deliver_sigint()
|
||||
assert calls == ["cancel"]
|
||||
|
||||
|
||||
async def test_off_loop_prompt_keeps_turn_alive(
|
||||
router: None,
|
||||
capsys: pytest_module.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A prompt's SIGINT target shields the turn; afterwards the turn cancels.
|
||||
|
||||
Regression (badcb3c, revisited): the router resolves the innermost target
|
||||
per signal instead of freezing cancel state into the handler, so a mid-turn
|
||||
prompt neither aborts the turn nor freezes turn-cancel.
|
||||
"""
|
||||
del router
|
||||
started = asyncio.Event()
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def hang() -> None:
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.sleep(3600)
|
||||
except asyncio.CancelledError:
|
||||
cancelled.set()
|
||||
raise
|
||||
|
||||
task = asyncio.create_task(hang())
|
||||
await started.wait()
|
||||
with sigint_cancels(task):
|
||||
with off_loop_prompt():
|
||||
await _deliver_sigint()
|
||||
assert not task.cancelling()
|
||||
err = capsys.readouterr().err
|
||||
assert "Ctrl+C ignored while a prompt is waiting" in err
|
||||
# The prompt returned (outer context still active): SIGINT cancels now.
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await asyncio.wait_for(task, timeout=5)
|
||||
assert cancelled.is_set()
|
||||
|
||||
|
||||
async def test_off_loop_prompt_cancel_hook_is_preferred(router: None) -> None:
|
||||
"""A poll-based prompt read gets cancelled instead of the enclosing turn."""
|
||||
del router
|
||||
hooks: list[str] = []
|
||||
turn_calls: list[str] = []
|
||||
with sigint_cancels(FakeTarget(turn_calls, "turn")), off_loop_prompt(lambda: hooks.append("read")):
|
||||
await _deliver_sigint()
|
||||
assert hooks == ["read"]
|
||||
assert turn_calls == []
|
||||
|
||||
|
||||
async def test_sigint_cancels_off_loop_prompt_read(router: None) -> None:
|
||||
"""Wiring: SIGINT aborts a poll-based read (not the turn) and clears the flag."""
|
||||
del router
|
||||
turn_calls: list[str] = []
|
||||
seen: list[str] = []
|
||||
|
||||
def _poll_read(prompt: str, timeout: float) -> str:
|
||||
"""Stand-in for ``read_line_with_timeout``: polls the cancel flag."""
|
||||
del prompt
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if prompt_cancelled():
|
||||
seen.append("cancelled")
|
||||
raise PromptCancelledError
|
||||
time.sleep(0.01)
|
||||
seen.append("timed out")
|
||||
return ""
|
||||
|
||||
with temporary_backend(ScriptedBackend([])):
|
||||
configure_prompting(pause_factory=off_loop_prompt)
|
||||
with sigint_cancels(FakeTarget(turn_calls, "turn")):
|
||||
reader = asyncio.create_task(run_cancellable_prompt_async(_poll_read, "p> ", 30.0))
|
||||
await asyncio.sleep(0.05)
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
with pytest.raises(PromptCancelledError):
|
||||
await asyncio.wait_for(reader, timeout=5)
|
||||
assert seen == ["cancelled"]
|
||||
assert turn_calls == []
|
||||
assert not prompt_cancelled() # cleared for the next prompt
|
||||
|
||||
|
||||
async def test_pause_task_cancel_for_prompt_swaps_sigint(router: None) -> None:
|
||||
"""A main-thread blocking read takes SIGINT as a KeyboardInterrupt."""
|
||||
del router
|
||||
routed = signal.getsignal(signal.SIGINT)
|
||||
assert routed is not raise_keyboard_interrupt_sigint
|
||||
with pause_task_cancel_for_prompt():
|
||||
assert signal.getsignal(signal.SIGINT) is raise_keyboard_interrupt_sigint
|
||||
with pause_task_cancel_for_prompt():
|
||||
assert signal.getsignal(signal.SIGINT) is raise_keyboard_interrupt_sigint
|
||||
# Inner exit must not resume the router while the outer read blocks.
|
||||
assert signal.getsignal(signal.SIGINT) is raise_keyboard_interrupt_sigint
|
||||
assert signal.getsignal(signal.SIGINT) is routed
|
||||
|
||||
|
||||
async def test_prompt_continue_limit_under_pause(
|
||||
router: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Synchronous prompts read on the main thread, where SIGINT is a KI."""
|
||||
del router
|
||||
backend = ScriptedBackend(["y"])
|
||||
|
||||
def _read_line(prompt: str, *, default: str | None = None, completions: object = None) -> str:
|
||||
del prompt, default, completions
|
||||
assert signal.getsignal(signal.SIGINT) is raise_keyboard_interrupt_sigint
|
||||
return "y"
|
||||
|
||||
monkeypatch.setattr(backend, "read_line", _read_line)
|
||||
with temporary_backend(backend):
|
||||
assert prompt_continue_limit("too many rounds") is True
|
||||
|
||||
|
||||
async def test_run_cancellable_keeps_router_installed(router: None) -> None:
|
||||
"""Regression: after a turn SIGINT must not be left as SIG_DFL.
|
||||
|
||||
``loop.remove_signal_handler`` leaves SIG_DFL, which would terminate the
|
||||
process on a stray Ctrl+C between turns instead of letting the REPL catch
|
||||
a KeyboardInterrupt.
|
||||
process on a stray Ctrl+C between turns; the session router stays installed.
|
||||
"""
|
||||
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")
|
||||
|
||||
previous = signal.getsignal(signal.SIGINT)
|
||||
del router
|
||||
|
||||
async def quick() -> None:
|
||||
return None
|
||||
|
||||
try:
|
||||
await run_cancellable(quick())
|
||||
assert signal.getsignal(signal.SIGINT) is previous
|
||||
assert signal.getsignal(signal.SIGINT) is not signal.SIG_DFL
|
||||
finally:
|
||||
signal.signal(signal.SIGINT, signal.default_int_handler)
|
||||
await run_cancellable(quick())
|
||||
assert sigint_routed()
|
||||
assert signal.getsignal(signal.SIGINT) is not signal.SIG_DFL
|
||||
assert signal.getsignal(signal.SIGINT) is not signal.default_int_handler
|
||||
|
||||
|
||||
async def test_sleep_cancellable_cancelled_by_sigint(router: None) -> None:
|
||||
"""The auto-retry countdown is cancelled by Ctrl+C (was a teardown window)."""
|
||||
del router
|
||||
waiter = asyncio.create_task(sleep_cancellable(30))
|
||||
await asyncio.sleep(0)
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
assert await asyncio.wait_for(waiter, timeout=5) is False
|
||||
|
||||
@@ -186,7 +186,8 @@ def test_complete_slash_args_from_registry(tmp_path: object) -> None:
|
||||
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 "--verbose" in complete_slash_args(state, "/history", "--v")
|
||||
assert complete_slash_args(state, "/history", "la") == ["last"]
|
||||
assert "32" in complete_slash_args(state, "/rounds", "3")
|
||||
|
||||
from plyngent.agent.todo_stack import TodoStack
|
||||
|
||||
@@ -134,8 +134,8 @@ async def test_help_history_no_fake_options(state: ReplState, capsys: pytest.Cap
|
||||
assert await handle_slash(state, "/help history") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "Usage: /history" in out
|
||||
# Real flags for full/preview are documented; Click's meta --help is not.
|
||||
assert "--full" in out
|
||||
# Real flags are documented; Click's meta --help is not.
|
||||
assert "--verbose" in out
|
||||
assert "--preview" in out
|
||||
assert " --help" not in out
|
||||
|
||||
@@ -498,70 +498,6 @@ async def test_resume(state: ReplState) -> None:
|
||||
assert await handle_slash(state, f"/resume {sid}") is True
|
||||
|
||||
|
||||
async def test_history(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage
|
||||
|
||||
state.agent.messages = [
|
||||
UserChatMessage(content="hello"),
|
||||
AssistantChatMessage(content="hi there"),
|
||||
]
|
||||
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
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""CLI surface for skills: ``/skills``, the system-prompt catalog, and writes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
import tomlkit
|
||||
|
||||
from plyngent.cli.slash import handle_slash
|
||||
from plyngent.cli.state import ReplState
|
||||
from plyngent.config.models import DatabaseConfig, OpenAIProvider
|
||||
from plyngent.config.store import ConfigStore
|
||||
from plyngent.memory import MemoryStore
|
||||
from plyngent.prompting import NonInteractiveBackend, temporary_backend
|
||||
from plyngent.skills import SKILL_FILE
|
||||
from tests.test_prompting import ScriptedBackend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _write_skill(root: Path, name: str, *, description: str = "A test skill", body: str = "Body.") -> Path:
|
||||
directory = root / name
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
_ = (directory / SKILL_FILE).write_text(
|
||||
f"---\nname: {name}\ndescription: {description}\n---\n\n{body}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return directory
|
||||
|
||||
|
||||
def _config_doc(tmp_path: Path, *, paths: list[Path] | None = None, extra: str = "") -> tomlkit.TOMLDocument:
|
||||
lines = ["[skills]", "discover = []"]
|
||||
if paths:
|
||||
rendered = ", ".join(f'"{path}"' for path in paths)
|
||||
lines.append(f"paths = [{rendered}]")
|
||||
if extra:
|
||||
lines.append(extra)
|
||||
return tomlkit.parse("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
async def _state(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
skills_dir: Path | None = None,
|
||||
tools_enabled: bool = False,
|
||||
extra: str = "",
|
||||
) -> ReplState:
|
||||
memory = await MemoryStore.open(DatabaseConfig())
|
||||
provider = OpenAIProvider(access_key_or_token="sk-test")
|
||||
document = _config_doc(tmp_path, paths=[skills_dir] if skills_dir is not None else None, extra=extra)
|
||||
config = ConfigStore(path=tmp_path / "plyngent.toml", document=document)
|
||||
config.providers = {"local": provider}
|
||||
# The skills root sits outside the workspace: reading it works through the
|
||||
# static read-only roots, writing must stay denied by the path policy.
|
||||
workspace = tmp_path / "project"
|
||||
workspace.mkdir(exist_ok=True)
|
||||
state = ReplState(
|
||||
config=config,
|
||||
memory=memory,
|
||||
workspace=workspace,
|
||||
provider_name="local",
|
||||
provider=provider,
|
||||
model="gpt-test",
|
||||
tools_enabled=tools_enabled,
|
||||
quiet=True,
|
||||
)
|
||||
await state.new_session("t")
|
||||
return state
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def skills_state(tmp_path: Path) -> AsyncIterator[ReplState]:
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir()
|
||||
_ = _write_skill(root, "acme-deploy", description="Deploy the acme stack", body="Use pdftotext.")
|
||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=True)
|
||||
yield state
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
async def test_slash_status_counts_skills(skills_state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert await handle_slash(skills_state, "/status") is True
|
||||
assert "skills=1" in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_skills_are_discovered_from_a_configured_root(tmp_path: Path) -> None:
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir()
|
||||
_ = _write_skill(root, "acme-deploy")
|
||||
state = await _state(tmp_path, skills_dir=root)
|
||||
try:
|
||||
assert state.skills is not None
|
||||
assert [skill.name for skill in state.skills.skills] == ["acme-deploy"]
|
||||
assert state.instance_state.skills is state.skills
|
||||
finally:
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
async def test_slash_skills_lists_search_and_reads(
|
||||
skills_state: ReplState,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
assert await handle_slash(skills_state, "/skills") is True
|
||||
out = capsys.readouterr().out
|
||||
assert "skills: 1" in out
|
||||
assert "acme-deploy" in out
|
||||
assert "Deploy the acme stack" in out
|
||||
|
||||
assert await handle_slash(skills_state, "/skills search pdftotext") is True
|
||||
assert "acme-deploy/SKILL.md:" in capsys.readouterr().out
|
||||
|
||||
assert await handle_slash(skills_state, "/skills read acme-deploy") is True
|
||||
assert "description: Deploy the acme stack" in capsys.readouterr().out
|
||||
|
||||
assert await handle_slash(skills_state, "/skills read acme-deploy --file SKILL.md") is True
|
||||
assert "acme-deploy" in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_slash_skills_reload_picks_up_a_new_skill(
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir()
|
||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=True)
|
||||
try:
|
||||
assert await handle_slash(state, "/skills") is True
|
||||
assert "skills: 0" in capsys.readouterr().out
|
||||
_ = _write_skill(root, "added-later")
|
||||
assert await handle_slash(state, "/skills reload") is True
|
||||
assert "skills reloaded: 1 visible" in capsys.readouterr().out
|
||||
assert await handle_slash(state, "/skills") is True
|
||||
assert "added-later" in capsys.readouterr().out
|
||||
finally:
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
async def test_slash_skills_reports_a_disabled_feature(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
state = await _state(tmp_path, extra="enabled = false")
|
||||
try:
|
||||
assert await handle_slash(state, "/skills") is True
|
||||
assert "skills: disabled" in capsys.readouterr().out
|
||||
assert state.skills is None
|
||||
assert state.instance_state.skills is None
|
||||
finally:
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
async def test_slash_skills_requires_a_target(skills_state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
for command in ("/skills search", "/skills read"):
|
||||
assert await handle_slash(skills_state, command) is True
|
||||
assert "requires" in capsys.readouterr().err
|
||||
|
||||
|
||||
async def test_skill_tools_are_offered_and_can_read_skill_files(
|
||||
skills_state: ReplState,
|
||||
) -> None:
|
||||
"""The roots are read-only roots: path tools read them, writes are denied."""
|
||||
assert skills_state.agent.tools is not None
|
||||
assert skills_state.agent.tools.get("skill_list") is not None
|
||||
|
||||
skill_file = skills_state.skills.skills[0].skill_file if skills_state.skills else None
|
||||
assert skill_file is not None
|
||||
read = await skills_state.agent.tools.execute("read_file", f'{{"path": "{skill_file}"}}')
|
||||
assert "Deploy the acme stack" in read
|
||||
|
||||
denied = await skills_state.agent.tools.execute(
|
||||
"write_file",
|
||||
f'{{"path": "{skill_file}", "content": "x"}}',
|
||||
)
|
||||
assert denied.startswith("error:")
|
||||
assert "escapes workspace root" in denied
|
||||
assert "Deploy the acme stack" in skill_file.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
async def test_skill_tools_are_absent_when_disabled(tmp_path: Path) -> None:
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir()
|
||||
_ = _write_skill(root, "acme")
|
||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=True, extra="enabled = false")
|
||||
try:
|
||||
assert state.agent.tools is not None
|
||||
assert state.agent.tools.get("skill_list") is None
|
||||
assert state.agent.tools.get("read_file") is not None
|
||||
finally:
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
async def test_skills_catalog_is_folded_into_the_system_prompt(tmp_path: Path) -> None:
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir()
|
||||
_ = _write_skill(root, "acme-deploy", description="Deploy the acme stack")
|
||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=True)
|
||||
try:
|
||||
prompt = state.agent.system_prompt or ""
|
||||
assert "### Skills" in prompt
|
||||
assert "- acme-deploy (config-user): Deploy the acme stack" in prompt
|
||||
assert "`skill_read`" in prompt
|
||||
# The body stays on disk: only the catalog goes into the prompt.
|
||||
assert "Body." not in prompt
|
||||
finally:
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
async def test_skills_catalog_respects_the_cap(tmp_path: Path) -> None:
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir()
|
||||
for name in ("alpha", "beta", "gamma"):
|
||||
_ = _write_skill(root, name)
|
||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=True, extra="max_catalog_skills = 1")
|
||||
try:
|
||||
prompt = state.agent.system_prompt or ""
|
||||
assert "alpha" in prompt
|
||||
assert "beta" not in prompt
|
||||
assert "… and 2 more" in prompt
|
||||
finally:
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
async def test_skills_catalog_can_be_switched_off(tmp_path: Path) -> None:
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir()
|
||||
_ = _write_skill(root, "acme")
|
||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=True, extra="inject_catalog = false")
|
||||
try:
|
||||
assert "### Skills" not in (state.agent.system_prompt or "")
|
||||
# The tools are still offered; only the prompt block is gone.
|
||||
assert state.agent.tools is not None
|
||||
assert state.agent.tools.get("skill_list") is not None
|
||||
finally:
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
async def test_skills_catalog_needs_tools(tmp_path: Path) -> None:
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir()
|
||||
_ = _write_skill(root, "acme")
|
||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=False)
|
||||
try:
|
||||
assert state.agent.tools is None
|
||||
assert "### Skills" not in (state.agent.system_prompt or "")
|
||||
finally:
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
async def test_slash_skills_notes_when_tools_are_off(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir()
|
||||
_ = _write_skill(root, "acme")
|
||||
state = await _state(tmp_path, skills_dir=root, tools_enabled=False)
|
||||
try:
|
||||
assert state.tools_enabled is False
|
||||
assert await handle_slash(state, "/skills list") is True
|
||||
assert "tools are off" in capsys.readouterr().out
|
||||
finally:
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
def test_prompt_skill_write_confirm_noninteractive_denies() -> None:
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.cli.limits import prompt_skill_write_confirm
|
||||
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
decision = prompt_skill_write_confirm(Path("/skills/acme"), "create", "create skill", 1.0)
|
||||
assert decision.allow is False
|
||||
|
||||
|
||||
def test_prompt_skill_write_confirm_answers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.cli import limits
|
||||
|
||||
target = Path("/skills/acme")
|
||||
with temporary_backend(ScriptedBackend([])):
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "o\n")
|
||||
once = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "s\n")
|
||||
session = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "n\n")
|
||||
denied = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: None)
|
||||
timed_out = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||
assert (once.allow, once.session) == (True, False)
|
||||
assert (session.allow, session.session) == (True, True)
|
||||
assert denied.allow is False
|
||||
assert timed_out.allow is False
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.config import load
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_skills_section_defaults(tmp_path: Path) -> None:
|
||||
path = tmp_path / "c.toml"
|
||||
_ = path.write_text("", encoding="utf-8")
|
||||
store = load(path)
|
||||
assert store.skills_config.enabled is True
|
||||
assert store.skills_config.paths == []
|
||||
assert store.skills_config.discover == ["plyngent", "claude"]
|
||||
assert store.skills_config.include_project_roots is True
|
||||
assert store.skills_config.inject_catalog is True
|
||||
assert store.skills_config.max_catalog_skills == 50
|
||||
assert store.skills_config.allow_write is False
|
||||
|
||||
|
||||
def test_skills_section_parse(tmp_path: Path) -> None:
|
||||
path = tmp_path / "c.toml"
|
||||
_ = path.write_text(
|
||||
"""
|
||||
[skills]
|
||||
enabled = false
|
||||
paths = ["~/team-skills", "/srv/skills"]
|
||||
discover = ["plyngent"]
|
||||
include_project_roots = false
|
||||
inject_catalog = false
|
||||
max_catalog_skills = 5
|
||||
allow_write = true
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
cfg = load(path).skills_config
|
||||
assert cfg.enabled is False
|
||||
assert cfg.paths == ["~/team-skills", "/srv/skills"]
|
||||
assert cfg.discover == ["plyngent"]
|
||||
assert cfg.include_project_roots is False
|
||||
assert cfg.inject_catalog is False
|
||||
assert cfg.max_catalog_skills == 5
|
||||
assert cfg.allow_write is True
|
||||
|
||||
|
||||
def test_skills_section_invalid_falls_back(tmp_path: Path) -> None:
|
||||
"""An unknown discovery source or a bad type degrades to the defaults."""
|
||||
path = tmp_path / "c.toml"
|
||||
_ = path.write_text(
|
||||
"""
|
||||
[skills]
|
||||
discover = ["codex"]
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert load(path).skills_config.discover == ["plyngent", "claude"]
|
||||
|
||||
path2 = tmp_path / "d.toml"
|
||||
_ = path2.write_text(
|
||||
"""
|
||||
[skills]
|
||||
enabled = "yes"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert load(path2).skills_config.enabled is True
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -9,15 +11,33 @@ from plyngent.prompting import (
|
||||
FormField,
|
||||
NonInteractiveBackend,
|
||||
NonInteractiveError,
|
||||
PromptCancelledError,
|
||||
_read_line_with_timeout_posix,
|
||||
ask,
|
||||
cancel_prompt_read,
|
||||
choose,
|
||||
configure_prompting,
|
||||
confirm,
|
||||
form,
|
||||
prompt_cancelled,
|
||||
read_line_with_timeout,
|
||||
reset_prompting,
|
||||
run_cancellable_prompt_async,
|
||||
run_prompt_async,
|
||||
temporary_backend,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator, Iterator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_prompting_state() -> Iterator[None]:
|
||||
"""Prompting state (backend, SIGINT guard, cancel flag) is process-global."""
|
||||
reset_prompting()
|
||||
yield
|
||||
reset_prompting()
|
||||
|
||||
|
||||
def _ready_poll(r, w, x, t):
|
||||
return (r, w, x)
|
||||
@@ -51,6 +71,43 @@ def test_read_line_with_timeout_negative_timeout() -> None:
|
||||
assert read_line_with_timeout("p> ", -1) is None
|
||||
|
||||
|
||||
def test_read_line_with_timeout_cancelled() -> None:
|
||||
"""A cancel request (SIGINT during an off-loop prompt) ends the read."""
|
||||
stream = io.StringIO()
|
||||
cancel_prompt_read()
|
||||
try:
|
||||
assert prompt_cancelled()
|
||||
with pytest.raises(PromptCancelledError):
|
||||
_read_line_with_timeout_posix("p> ", 5.0, stream=stream, poll=_never_poll)
|
||||
finally:
|
||||
stream.close()
|
||||
|
||||
|
||||
async def test_run_prompt_async_clears_cancel_flag() -> None:
|
||||
"""A stale cancel request must not leak into the next prompt read."""
|
||||
cancel_prompt_read()
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
assert await run_prompt_async(lambda: "ok") == "ok"
|
||||
assert not prompt_cancelled()
|
||||
|
||||
|
||||
async def test_run_prompt_async_passes_cancel_hook_only_when_cancellable() -> None:
|
||||
"""The CLI guard gets the read's cancel hook for poll-based reads only."""
|
||||
seen: list[object] = []
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _guard(cancel: object) -> Generator[None]:
|
||||
seen.append(cancel)
|
||||
yield
|
||||
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
# temporary_backend resets the guard, so install it inside.
|
||||
configure_prompting(pause_factory=_guard)
|
||||
assert await run_cancellable_prompt_async(lambda: "a") == "a"
|
||||
assert await run_prompt_async(lambda: "b") == "b"
|
||||
assert seen == [cancel_prompt_read, None]
|
||||
|
||||
|
||||
class ScriptedBackend:
|
||||
"""Deterministic backend for unit tests."""
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -97,6 +98,26 @@ async def test_close_terminates_subprocess() -> None:
|
||||
assert proc.returncode is not None
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups only")
|
||||
async def test_server_spawned_outside_our_process_group() -> None:
|
||||
"""Regression: a terminal Ctrl+C must not reach the MCP server.
|
||||
|
||||
The tty delivers SIGINT to every process in the foreground process group, so
|
||||
a server spawned in *our* group died on any Ctrl+C during a turn (killing
|
||||
in-flight tool calls and leaving the connection dead). ``start_new_session``
|
||||
moves the server into its own session/group.
|
||||
"""
|
||||
connection = McpServerConnection("fake", _server_config())
|
||||
await connection.start()
|
||||
try:
|
||||
proc = connection._proc
|
||||
assert proc is not None
|
||||
assert os.getpgid(proc.pid) != os.getpgid(0)
|
||||
assert os.getsid(proc.pid) != os.getsid(0)
|
||||
finally:
|
||||
await connection.aclose()
|
||||
|
||||
|
||||
async def test_manager_start_status_and_call() -> None:
|
||||
manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[]))
|
||||
await manager.ensure_started()
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.config.models import SkillsConfig
|
||||
from plyngent.skills import SKILL_FILE, Skill, SkillStore, build_roots, validate_skill_name
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _write_skill(root: Path, name: str, *, description: str | None = "A test skill", body: str = "Body.") -> Path:
|
||||
directory = root / name
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
front = "---\n"
|
||||
if description is not None:
|
||||
front += f"name: {name}\ndescription: {description}\n"
|
||||
front += "---\n\n"
|
||||
_ = (directory / SKILL_FILE).write_text(front + body + "\n", encoding="utf-8")
|
||||
return directory
|
||||
|
||||
|
||||
def test_build_roots_orders_explicit_paths_first(tmp_path: Path) -> None:
|
||||
config = SkillsConfig(paths=["~/team-skills", "/srv/skills"])
|
||||
roots = build_roots(
|
||||
config,
|
||||
workspace=tmp_path / "proj",
|
||||
user_config_dir=tmp_path / "config",
|
||||
home=tmp_path / "home",
|
||||
)
|
||||
assert [(r.origin, r.scope, str(r.path)) for r in roots] == [
|
||||
("config", "user", str(tmp_path / "home" / "team-skills")),
|
||||
("config", "user", "/srv/skills"),
|
||||
("plyngent", "user", str(tmp_path / "config" / "skills")),
|
||||
("claude", "user", str(tmp_path / "home" / ".claude" / "skills")),
|
||||
("claude", "project", str(tmp_path / "proj" / ".claude" / "skills")),
|
||||
]
|
||||
|
||||
|
||||
def test_build_roots_respects_discover_and_project_switch(tmp_path: Path) -> None:
|
||||
config = SkillsConfig(discover=["plyngent"], include_project_roots=False)
|
||||
roots = build_roots(config, workspace=tmp_path, user_config_dir=tmp_path / "cfg", home=tmp_path)
|
||||
assert [r.origin for r in roots] == ["plyngent"]
|
||||
|
||||
|
||||
def test_store_discovers_skills_and_labels_their_root(tmp_path: Path) -> None:
|
||||
user = tmp_path / "config" / "skills"
|
||||
_ = _write_skill(user, "pdf-processing", description="Extract text from PDFs")
|
||||
store = SkillStore(
|
||||
build_roots(SkillsConfig(discover=["plyngent"]), workspace=tmp_path, user_config_dir=tmp_path / "config")
|
||||
)
|
||||
skills = store.skills
|
||||
assert [(s.name, s.description, s.root.label) for s in skills] == [
|
||||
("pdf-processing", "Extract text from PDFs", "plyngent-user"),
|
||||
]
|
||||
assert skills[0].skill_file.name == SKILL_FILE
|
||||
|
||||
|
||||
def test_store_lists_skills_from_several_roots_sorted(tmp_path: Path) -> None:
|
||||
config_dir = tmp_path / "config"
|
||||
_ = _write_skill(config_dir / "skills", "zeta")
|
||||
_ = _write_skill(tmp_path / "home" / ".claude" / "skills", "alpha")
|
||||
store = SkillStore(
|
||||
build_roots(SkillsConfig(), workspace=tmp_path, user_config_dir=config_dir, home=tmp_path / "home")
|
||||
)
|
||||
assert [s.name for s in store.skills] == ["alpha", "zeta"]
|
||||
assert [s.root.origin for s in store.skills] == ["claude", "plyngent"]
|
||||
|
||||
|
||||
def test_explicit_path_wins_and_shadowed_copy_stays_listed(tmp_path: Path) -> None:
|
||||
explicit = tmp_path / "team"
|
||||
config_dir = tmp_path / "config"
|
||||
_ = _write_skill(explicit, "shared", description="Team copy")
|
||||
_ = _write_skill(config_dir / "skills", "shared", description="User copy")
|
||||
config = SkillsConfig(paths=[str(explicit)], discover=["plyngent"])
|
||||
store = SkillStore(build_roots(config, workspace=tmp_path, user_config_dir=config_dir))
|
||||
|
||||
found = store.find("shared")
|
||||
assert found is not None
|
||||
assert found.description == "Team copy"
|
||||
assert [s.description for s in store.shadowed()["shared"]] == ["Team copy", "User copy"]
|
||||
assert len(store.all_skills) == 2
|
||||
|
||||
|
||||
def test_find_is_case_insensitive_fallback(tmp_path: Path) -> None:
|
||||
_ = _write_skill(tmp_path / "skills", "pdf-processing")
|
||||
store = SkillStore(build_roots(SkillsConfig(discover=["plyngent"]), workspace=tmp_path, user_config_dir=tmp_path))
|
||||
assert store.find("PDF-Processing") is not None
|
||||
assert store.find("nope") is None
|
||||
|
||||
|
||||
def test_root_itself_may_be_a_skill(tmp_path: Path) -> None:
|
||||
single = tmp_path / "my-skill"
|
||||
single.mkdir()
|
||||
_ = (single / SKILL_FILE).write_text("---\nname: my-skill\ndescription: One-off\n---\n\nBody.\n", encoding="utf-8")
|
||||
store = SkillStore(build_roots(SkillsConfig(paths=[str(single)]), workspace=tmp_path, user_config_dir=tmp_path))
|
||||
assert [s.name for s in store.skills] == ["my-skill"]
|
||||
|
||||
|
||||
def test_missing_roots_are_not_an_error(tmp_path: Path) -> None:
|
||||
store = SkillStore(
|
||||
build_roots(SkillsConfig(), workspace=tmp_path, user_config_dir=tmp_path / "nope", home=tmp_path)
|
||||
)
|
||||
assert store.skills == []
|
||||
assert store.shadowed() == {}
|
||||
|
||||
|
||||
def test_description_falls_back_to_the_first_paragraph(tmp_path: Path) -> None:
|
||||
directory = tmp_path / "skills" / "no-desc"
|
||||
directory.mkdir(parents=True)
|
||||
_ = (directory / SKILL_FILE).write_text(
|
||||
"# Heading\n\nFirst paragraph of the skill.\n\nSecond paragraph.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
store = SkillStore(build_roots(SkillsConfig(discover=["plyngent"]), workspace=tmp_path, user_config_dir=tmp_path))
|
||||
skill = store.skills[0]
|
||||
assert skill.description == "First paragraph of the skill."
|
||||
assert skill.declared_name is None
|
||||
assert any("no frontmatter description" in issue for issue in skill.issues)
|
||||
|
||||
|
||||
def test_unusable_declared_name_falls_back_to_the_directory(tmp_path: Path) -> None:
|
||||
directory = tmp_path / "skills" / "dir-name"
|
||||
directory.mkdir(parents=True)
|
||||
_ = (directory / SKILL_FILE).write_text(
|
||||
'---\nname: "PDF Skills"\ndescription: Mixed case\n---\n\nBody.\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
store = SkillStore(build_roots(SkillsConfig(discover=["plyngent"]), workspace=tmp_path, user_config_dir=tmp_path))
|
||||
skill = store.skills[0]
|
||||
assert skill.name == "dir-name"
|
||||
assert skill.declared_name == "PDF Skills"
|
||||
assert any("not usable" in issue for issue in skill.issues)
|
||||
|
||||
|
||||
def test_broken_frontmatter_still_yields_a_listable_skill(tmp_path: Path) -> None:
|
||||
directory = tmp_path / "skills" / "broken"
|
||||
directory.mkdir(parents=True)
|
||||
_ = (directory / SKILL_FILE).write_text("---\nname: broken\nbroken line\n---\n\nBody text.\n", encoding="utf-8")
|
||||
store = SkillStore(build_roots(SkillsConfig(discover=["plyngent"]), workspace=tmp_path, user_config_dir=tmp_path))
|
||||
skill = store.skills[0]
|
||||
assert skill.name == "broken"
|
||||
assert skill.description # falls back to the body
|
||||
assert any("frontmatter not parsed" in issue for issue in skill.issues)
|
||||
|
||||
|
||||
def test_cache_picks_up_an_edit_and_reload_forces_a_rescan(tmp_path: Path) -> None:
|
||||
root = tmp_path / "skills"
|
||||
directory = _write_skill(root, "cached", description="Before")
|
||||
store = SkillStore(build_roots(SkillsConfig(discover=["plyngent"]), workspace=tmp_path, user_config_dir=tmp_path))
|
||||
assert store.skills[0].description == "Before"
|
||||
|
||||
_ = (directory / SKILL_FILE).write_text(
|
||||
"---\nname: cached\ndescription: After\n---\n\nBody.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert store.skills[0].description == "After"
|
||||
|
||||
_ = _write_skill(root, "added")
|
||||
store.reload()
|
||||
assert [s.name for s in store.skills] == ["added", "cached"]
|
||||
|
||||
|
||||
def test_default_write_root_prefers_our_own_directory(tmp_path: Path) -> None:
|
||||
config_dir = tmp_path / "config"
|
||||
store = SkillStore(build_roots(SkillsConfig(), workspace=tmp_path, user_config_dir=config_dir, home=tmp_path))
|
||||
root = store.default_write_root()
|
||||
assert root is not None
|
||||
assert root.origin == "plyngent"
|
||||
assert root.path == config_dir / "skills"
|
||||
|
||||
explicit = SkillStore(
|
||||
build_roots(SkillsConfig(paths=["/srv/skills"], discover=[]), workspace=tmp_path, user_config_dir=config_dir)
|
||||
)
|
||||
fallback = explicit.default_write_root()
|
||||
assert fallback is not None and fallback.origin == "config"
|
||||
assert SkillStore([]).default_write_root() is None
|
||||
|
||||
|
||||
def test_validate_skill_name() -> None:
|
||||
assert validate_skill_name("pdf-processing") is None
|
||||
assert validate_skill_name("a1") is None
|
||||
assert validate_skill_name("") is not None
|
||||
assert validate_skill_name("PDF") is not None
|
||||
assert validate_skill_name("with_underscore") is not None
|
||||
assert validate_skill_name("x" * 65) is not None
|
||||
|
||||
|
||||
def test_skill_is_a_frozen_record(tmp_path: Path) -> None:
|
||||
directory = _write_skill(tmp_path / "skills", "frozen")
|
||||
store = SkillStore(build_roots(SkillsConfig(discover=["plyngent"]), workspace=tmp_path, user_config_dir=tmp_path))
|
||||
skill = store.skills[0]
|
||||
assert isinstance(skill, Skill)
|
||||
assert skill.path == directory
|
||||
assert skill.metadata["name"] == "frozen"
|
||||
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.skills.frontmatter import (
|
||||
FrontmatterError,
|
||||
parse_frontmatter,
|
||||
parse_yaml_subset,
|
||||
split_frontmatter,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_frontmatter_basic() -> None:
|
||||
text = (
|
||||
"---\n"
|
||||
"name: pdf-processing\n"
|
||||
'description: "Extract text and tables from PDFs"\n'
|
||||
"version: 2\n"
|
||||
"license: MIT\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# PDF processing\n"
|
||||
"\n"
|
||||
"Use pdftotext first.\n"
|
||||
)
|
||||
metadata, body = parse_frontmatter(text)
|
||||
assert metadata == {
|
||||
"name": "pdf-processing",
|
||||
"description": "Extract text and tables from PDFs",
|
||||
"version": 2,
|
||||
"license": "MIT",
|
||||
}
|
||||
assert body.startswith("# PDF processing")
|
||||
assert "pdftotext" in body
|
||||
|
||||
|
||||
def test_parse_frontmatter_without_block_keeps_the_whole_text() -> None:
|
||||
text = "# Just a skill\n\nNo frontmatter here.\n"
|
||||
metadata, body = parse_frontmatter(text)
|
||||
assert metadata == {}
|
||||
assert body == text
|
||||
|
||||
|
||||
def test_parse_frontmatter_unterminated_block_is_not_frontmatter() -> None:
|
||||
text = "---\nname: x\n\nbody without a closing fence\n"
|
||||
metadata, body = parse_frontmatter(text)
|
||||
assert metadata == {}
|
||||
assert body == text
|
||||
|
||||
|
||||
def test_frontmatter_closing_dots_are_accepted() -> None:
|
||||
block, body = split_frontmatter("---\nname: x\n...\nbody\n")
|
||||
assert block == "name: x"
|
||||
assert body == "body\n"
|
||||
|
||||
|
||||
def test_yaml_subset_types_lists_and_booleans() -> None:
|
||||
block = "\n".join(
|
||||
[
|
||||
"name: acme",
|
||||
"read_only: true",
|
||||
"confirm: no",
|
||||
"retries: 3",
|
||||
"allowed-tools: [read_file, regex_files]",
|
||||
"tags:",
|
||||
" - alpha",
|
||||
" - beta",
|
||||
]
|
||||
)
|
||||
assert parse_yaml_subset(block) == {
|
||||
"name": "acme",
|
||||
"read_only": True,
|
||||
"confirm": False,
|
||||
"retries": 3,
|
||||
"allowed-tools": ["read_file", "regex_files"],
|
||||
"tags": ["alpha", "beta"],
|
||||
}
|
||||
|
||||
|
||||
def test_yaml_subset_plain_scalar_folds_continuation_lines() -> None:
|
||||
block = "description: A long description\n that continues on the next line\n"
|
||||
assert parse_yaml_subset(block) == {
|
||||
"description": "A long description that continues on the next line",
|
||||
}
|
||||
|
||||
|
||||
def test_yaml_subset_block_scalars() -> None:
|
||||
literal = "description: |\n first\n second\n"
|
||||
folded = "description: >\n first\n second\n"
|
||||
assert parse_yaml_subset(literal) == {"description": "first\nsecond"}
|
||||
assert parse_yaml_subset(folded) == {"description": "first second"}
|
||||
|
||||
|
||||
def test_yaml_subset_nested_mapping_is_skipped() -> None:
|
||||
"""Unsupported nesting must not lose the flat fields around it."""
|
||||
block = "name: acme\nmetadata:\n author: someone\n homepage: https://example.com\nlicense: MIT\n"
|
||||
assert parse_yaml_subset(block) == {"name": "acme", "license": "MIT"}
|
||||
|
||||
|
||||
def test_yaml_subset_comments_and_blank_lines() -> None:
|
||||
block = "# a comment\n\nname: acme\n"
|
||||
assert parse_yaml_subset(block) == {"name": "acme"}
|
||||
|
||||
|
||||
def test_yaml_subset_rejects_broken_lines() -> None:
|
||||
with pytest.raises(FrontmatterError):
|
||||
_ = parse_yaml_subset("name: acme\nnot a pair\n")
|
||||
with pytest.raises(FrontmatterError):
|
||||
_ = parse_yaml_subset("- orphan item\n")
|
||||
|
||||
|
||||
def test_yaml_subset_rejects_indented_top_level() -> None:
|
||||
with pytest.raises(FrontmatterError):
|
||||
_ = parse_yaml_subset(" name: acme\n")
|
||||
@@ -12,6 +12,7 @@ from plyngent.tools.chat import CHAT_TOOLS
|
||||
from plyngent.tools.file import FILE_TOOLS
|
||||
from plyngent.tools.net import NET_TOOLS
|
||||
from plyngent.tools.process import PROCESS_TOOLS
|
||||
from plyngent.tools.skills import SKILL_TOOLS
|
||||
from plyngent.tools.todo import TODO_TOOLS
|
||||
from plyngent.tools.vcs import VCS_TOOLS
|
||||
|
||||
@@ -19,7 +20,16 @@ from plyngent.tools.vcs import VCS_TOOLS
|
||||
def test_default_tool_names_match_group_lists() -> None:
|
||||
register_builtin_tools()
|
||||
selected = default_tool_definitions(surface="local")
|
||||
groups = [*ACCESS_TOOLS, *FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS, *TODO_TOOLS, *NET_TOOLS]
|
||||
groups = [
|
||||
*ACCESS_TOOLS,
|
||||
*FILE_TOOLS,
|
||||
*PROCESS_TOOLS,
|
||||
*VCS_TOOLS,
|
||||
*CHAT_TOOLS,
|
||||
*TODO_TOOLS,
|
||||
*SKILL_TOOLS,
|
||||
*NET_TOOLS,
|
||||
]
|
||||
assert sorted(t.name for t in selected) == sorted(t.name for t in groups)
|
||||
assert len(selected) == len(groups)
|
||||
|
||||
@@ -51,7 +61,7 @@ def test_read_only_classification() -> None:
|
||||
"read_file",
|
||||
"get_truncated",
|
||||
"glob_paths",
|
||||
"grep_files",
|
||||
"regex_files",
|
||||
"listdir",
|
||||
"tree",
|
||||
"todo_list",
|
||||
@@ -65,6 +75,9 @@ def test_read_only_classification() -> None:
|
||||
"vcs_log",
|
||||
"vcs_branch",
|
||||
"fetch",
|
||||
"skill_list",
|
||||
"skill_read",
|
||||
"skill_search",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ from __future__ import annotations
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.agent import ToolRegistry
|
||||
from plyngent.prompting import NonInteractiveBackend, temporary_backend
|
||||
from plyngent.prompting import NonInteractiveBackend, PromptCancelledError, temporary_backend
|
||||
from plyngent.tools.chat import CHAT_TOOLS, ask_user, choose_user, form_user, wait
|
||||
from tests.test_prompting import ScriptedBackend
|
||||
|
||||
@@ -146,6 +148,20 @@ async def test_wait_tool_times_out(monkeypatch) -> None:
|
||||
assert out == "waited 3s"
|
||||
|
||||
|
||||
async def test_wait_tool_cancelled_by_user(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ctrl+C during the wait prompt cancels the wait; the turn continues."""
|
||||
|
||||
def _cancelled(prompt: str, timeout: float) -> str:
|
||||
del prompt, timeout
|
||||
raise PromptCancelledError
|
||||
|
||||
monkeypatch.setattr(wait_module, "read_line_with_timeout", _cancelled)
|
||||
with temporary_backend(ScriptedBackend([])):
|
||||
registry = ToolRegistry([wait])
|
||||
out = await registry.execute("wait", '{"duration": 30}')
|
||||
assert out == "cancelled by user"
|
||||
|
||||
|
||||
async def test_wait_tool_prompt_two_lines_with_reason(monkeypatch) -> None:
|
||||
prompts: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
@@ -192,3 +208,170 @@ def test_wait_prompt_colored_on_tty(monkeypatch) -> None:
|
||||
prompt = wait_module._wait_prompt(5, reason="tests")
|
||||
assert "\x1b[36m" in prompt # cyan status
|
||||
assert "\x1b[33m" in prompt # yellow input prompt
|
||||
|
||||
|
||||
def test_shape_of_names_json_types() -> None:
|
||||
from plyngent.tools.chat.shape import first_error, shape_of
|
||||
|
||||
assert shape_of("x") == "a string"
|
||||
assert shape_of([]) == "an array"
|
||||
assert shape_of({}) == "an object"
|
||||
assert shape_of(True) == "a boolean" # bool is not reported as a number
|
||||
assert shape_of(3) == "a number"
|
||||
assert shape_of(3.5) == "a number"
|
||||
assert shape_of(None) == "null"
|
||||
assert first_error(None, "boom", "later") == "boom"
|
||||
assert first_error(None, None) is None
|
||||
|
||||
|
||||
async def test_ask_user_line_rejects_non_string_args() -> None:
|
||||
"""Mis-typed args get explained instead of reaching the human as garbage."""
|
||||
backend = ScriptedBackend(["unused"])
|
||||
with temporary_backend(backend):
|
||||
registry = ToolRegistry([ask_user])
|
||||
bad_question = await registry.execute("ask_user_line", json.dumps({"question": ["a", "b"]}))
|
||||
bad_default = await registry.execute(
|
||||
"ask_user_line",
|
||||
json.dumps({"question": "Which?", "default": 5}),
|
||||
)
|
||||
assert bad_question.startswith("error:")
|
||||
assert "`question`" in bad_question
|
||||
assert "got an array" in bad_question
|
||||
assert bad_default.startswith("error:")
|
||||
assert "`default`" in bad_default
|
||||
assert "got a number" in bad_default
|
||||
assert backend.lines == ["unused"] # the prompt never happened
|
||||
|
||||
|
||||
async def test_choose_user_rejects_string_options() -> None:
|
||||
"""``options="alpha, beta"`` used to build a menu of single characters."""
|
||||
backend = ScriptedBackend(["1"])
|
||||
with temporary_backend(backend):
|
||||
registry = ToolRegistry([choose_user])
|
||||
out = await registry.execute(
|
||||
"ask_user_choice",
|
||||
json.dumps({"question": "Pick", "options": "alpha, beta"}),
|
||||
)
|
||||
assert out.startswith("error:")
|
||||
assert "`options`" in out
|
||||
assert "not a string" in out
|
||||
assert '["alpha", "beta"]' in out
|
||||
assert backend.lines == ["1"]
|
||||
|
||||
|
||||
async def test_choose_user_rejects_object_options() -> None:
|
||||
registry = ToolRegistry([choose_user])
|
||||
out = await registry.execute(
|
||||
"ask_user_choice",
|
||||
json.dumps({"question": "Pick", "options": {"alpha": 1}}),
|
||||
)
|
||||
assert out.startswith("error:")
|
||||
assert "got an object" in out
|
||||
|
||||
|
||||
async def test_form_user_rejects_string_fields() -> None:
|
||||
backend = ScriptedBackend(["ncbm"], confirms=[True])
|
||||
with temporary_backend(backend):
|
||||
registry = ToolRegistry([form_user])
|
||||
out = await registry.execute(
|
||||
"ask_user_form",
|
||||
json.dumps({"title": "Setup", "fields": '[{"name": "user"}]'}),
|
||||
)
|
||||
assert out.startswith("error:")
|
||||
assert "`fields`" in out
|
||||
assert "not a string" in out
|
||||
assert backend.lines == ["ncbm"]
|
||||
|
||||
|
||||
async def test_form_user_rejects_non_object_field() -> None:
|
||||
registry = ToolRegistry([form_user])
|
||||
out = await registry.execute(
|
||||
"ask_user_form",
|
||||
json.dumps({"title": "Setup", "fields": ["user"]}),
|
||||
)
|
||||
assert out == "error: each field must be an object with name/prompt"
|
||||
|
||||
|
||||
async def test_wait_accepts_numeric_string_duration(monkeypatch) -> None:
|
||||
"""A model sending "5" instead of 5 still waits."""
|
||||
slept: list[float] = []
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
slept.append(seconds)
|
||||
|
||||
monkeypatch.setattr(wait_module.asyncio, "sleep", fake_sleep)
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
registry = ToolRegistry([wait])
|
||||
out = await registry.execute("wait", json.dumps({"duration": "5"}))
|
||||
assert out == "waited 5s"
|
||||
assert slept == [5.0]
|
||||
|
||||
|
||||
async def test_wait_accepts_fractional_duration(monkeypatch) -> None:
|
||||
slept: list[float] = []
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
slept.append(seconds)
|
||||
|
||||
monkeypatch.setattr(wait_module.asyncio, "sleep", fake_sleep)
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
registry = ToolRegistry([wait])
|
||||
out = await registry.execute("wait", json.dumps({"duration": 2.5}))
|
||||
assert out == "waited 2.5s"
|
||||
assert slept == [2.5]
|
||||
|
||||
|
||||
async def test_wait_rejects_bad_duration_shapes() -> None:
|
||||
cases: list[tuple[object, str]] = [
|
||||
("soon", "non-numeric string"),
|
||||
([5], "an array"),
|
||||
({"seconds": 5}, "an object"),
|
||||
(True, "a boolean"),
|
||||
]
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
registry = ToolRegistry([wait])
|
||||
for value, expected in cases:
|
||||
out = await registry.execute("wait", json.dumps({"duration": value}))
|
||||
assert out.startswith("error:"), f"{value!r} was accepted: {out!r}"
|
||||
assert "`duration`" in out
|
||||
assert expected in out, f"{value!r}: {out!r}"
|
||||
|
||||
|
||||
async def test_wait_rejects_non_string_reason() -> None:
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
registry = ToolRegistry([wait])
|
||||
out = await registry.execute("wait", json.dumps({"duration": 0, "reason": ["why"]}))
|
||||
assert out.startswith("error:")
|
||||
assert "`reason`" in out
|
||||
|
||||
|
||||
def test_wait_prompt_renders_fractional_seconds() -> None:
|
||||
prompt = wait_module._wait_prompt(2.5, reason=None)
|
||||
assert prompt.startswith("Waiting for 2.5s.")
|
||||
assert wait_module._wait_prompt(5, reason=None).startswith("Waiting for 5s.")
|
||||
|
||||
|
||||
async def test_choose_user_reports_missing_options() -> None:
|
||||
"""A call without the required ``options`` lists what the tool accepts."""
|
||||
backend = ScriptedBackend(["1"])
|
||||
with temporary_backend(backend):
|
||||
registry = ToolRegistry([choose_user])
|
||||
out = await registry.execute("ask_user_choice", json.dumps({"question": "Pick"}))
|
||||
assert out.startswith("error:")
|
||||
assert "missing required argument(s): options" in out
|
||||
assert "it accepts: allow_custom, default, options, question" in out
|
||||
assert backend.lines == ["1"] # the human was never asked
|
||||
|
||||
|
||||
async def test_form_user_reports_missing_fields() -> None:
|
||||
registry = ToolRegistry([form_user])
|
||||
out = await registry.execute("ask_user_form", json.dumps({"title": "Setup"}))
|
||||
assert out.startswith("error:")
|
||||
assert "missing required argument(s): fields" in out
|
||||
|
||||
|
||||
async def test_wait_reports_missing_duration() -> None:
|
||||
registry = ToolRegistry([wait])
|
||||
out = await registry.execute("wait", "{}")
|
||||
assert out.startswith("error:")
|
||||
assert "missing required argument(s): duration" in out
|
||||
|
||||
@@ -2,46 +2,46 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.tools.file.grep_files import grep_files
|
||||
from plyngent.tools.file.regex_files import regex_files
|
||||
from tests.test_tools.helpers import call_sync
|
||||
|
||||
|
||||
def test_grep_files_basic(workspace: object) -> None:
|
||||
def test_regex_files_basic(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
_ = (workspace / "a.py").write_text("hello world\nfoo\n", encoding="utf-8")
|
||||
_ = (workspace / "b.py").write_text("nope\n", encoding="utf-8")
|
||||
out = call_sync(grep_files, "hello")
|
||||
out = call_sync(regex_files, "hello")
|
||||
assert "a.py:1:" in out
|
||||
assert "hello world" in out
|
||||
assert "b.py" not in out
|
||||
|
||||
|
||||
def test_grep_files_case_insensitive(workspace: object) -> None:
|
||||
def test_regex_files_case_insensitive(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
_ = (workspace / "t.txt").write_text("Hello\n", encoding="utf-8")
|
||||
assert "no matches" in call_sync(grep_files, "hello")
|
||||
out = call_sync(grep_files, "hello", case_insensitive=True)
|
||||
assert "no matches" in call_sync(regex_files, "hello")
|
||||
out = call_sync(regex_files, "hello", case_insensitive=True)
|
||||
assert "t.txt:1:" in out
|
||||
|
||||
|
||||
def test_grep_files_invalid_regex(workspace: object) -> None:
|
||||
def test_regex_files_invalid_regex(workspace: object) -> None:
|
||||
del workspace
|
||||
assert "invalid regex" in call_sync(grep_files, "[")
|
||||
assert "invalid regex" in call_sync(regex_files, "[")
|
||||
|
||||
|
||||
def test_grep_files_skip_git(workspace: object) -> None:
|
||||
def test_regex_files_skip_git(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
(workspace / ".git").mkdir()
|
||||
_ = (workspace / ".git" / "x").write_text("secret\n", encoding="utf-8")
|
||||
_ = (workspace / "ok.txt").write_text("secret\n", encoding="utf-8")
|
||||
out = call_sync(grep_files, "secret")
|
||||
out = call_sync(regex_files, "secret")
|
||||
assert "ok.txt" in out
|
||||
assert ".git" not in out
|
||||
|
||||
|
||||
def test_grep_files_max_matches(workspace: object) -> None:
|
||||
def test_regex_files_max_matches(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
lines = "\n".join(f"match {i}" for i in range(10))
|
||||
_ = (workspace / "m.txt").write_text(lines + "\n", encoding="utf-8")
|
||||
out = call_sync(grep_files, "match", max_matches=3)
|
||||
out = call_sync(regex_files, "match", max_matches=3)
|
||||
assert "truncated" in out
|
||||
@@ -0,0 +1,419 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.config.models import SkillsConfig
|
||||
from plyngent.skills import SKILL_FILE, SkillStore, build_roots
|
||||
from plyngent.tools.context import InstanceState, SessionState, bind_instance, bind_session
|
||||
from plyngent.tools.skills import (
|
||||
SKILL_TOOL_NAMES,
|
||||
SkillWriteDecision,
|
||||
SkillWritePolicy,
|
||||
format_skill_table,
|
||||
set_skill_write_policy,
|
||||
skill_create,
|
||||
skill_edit,
|
||||
skill_list,
|
||||
skill_read,
|
||||
skill_search,
|
||||
)
|
||||
from plyngent.tools.workspace import set_path_denylist, set_static_read_roots, set_workspace_root
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _write_skill(root: Path, name: str, *, description: str | None = "A test skill", body: str = "Body.") -> Path:
|
||||
directory = root / name
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
front = "---\n"
|
||||
if description is not None:
|
||||
front += f"name: {name}\ndescription: {description}\n"
|
||||
front += "---\n\n"
|
||||
_ = (directory / SKILL_FILE).write_text(front + body + "\n", encoding="utf-8")
|
||||
return directory
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _bound(
|
||||
tmp_path: Path,
|
||||
*roots: Path,
|
||||
skills: SkillStore | None = None,
|
||||
session: bool = True,
|
||||
policy: SkillWritePolicy | None = None,
|
||||
) -> Generator[InstanceState]:
|
||||
"""Bind an instance whose skill store scans *roots*, like the CLI does."""
|
||||
workspace = tmp_path / "project"
|
||||
workspace.mkdir(exist_ok=True)
|
||||
store = (
|
||||
skills
|
||||
if skills is not None
|
||||
else SkillStore(
|
||||
build_roots(
|
||||
SkillsConfig(paths=[str(r) for r in roots], discover=[]), workspace=workspace, user_config_dir=tmp_path
|
||||
)
|
||||
)
|
||||
)
|
||||
instance = InstanceState(workspace_root=workspace, skills=store)
|
||||
session_state = SessionState(session_id=1) if session else None
|
||||
with bind_instance(instance), bind_session(session_state):
|
||||
_ = set_workspace_root(workspace)
|
||||
_ = set_static_read_roots(roots)
|
||||
if policy is not None:
|
||||
set_skill_write_policy(policy, instance=instance)
|
||||
yield instance
|
||||
|
||||
|
||||
def _write_root(tmp_path: Path) -> Path:
|
||||
"""An empty user-level skills directory to create skills in."""
|
||||
root = tmp_path / "ours"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _allow(*_args: object) -> SkillWriteDecision:
|
||||
return SkillWriteDecision(allow=True)
|
||||
|
||||
|
||||
def _deny(*_args: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def test_skill_list_without_a_store_returns_an_error(tmp_path: Path) -> None:
|
||||
with _bound(tmp_path, skills=None) as instance:
|
||||
instance.skills = None
|
||||
assert await skill_list.handler() == "error: skills are not available in this session"
|
||||
|
||||
|
||||
async def test_skill_list_reports_skills_roots_and_absent_dirs(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
_ = _write_skill(root, "acme-deploy", description="Deploy the acme stack")
|
||||
missing = tmp_path / "missing"
|
||||
with _bound(tmp_path, root, missing):
|
||||
out = await skill_list.handler()
|
||||
assert "skills: 1" in out
|
||||
assert "acme-deploy [config-user]" in out
|
||||
assert "Deploy the acme stack" in out
|
||||
assert str(root / "acme-deploy") in out
|
||||
assert "roots without a directory" in out
|
||||
assert str(missing) in out
|
||||
|
||||
|
||||
async def test_skill_list_marks_shadowed_copies(tmp_path: Path) -> None:
|
||||
high = tmp_path / "team"
|
||||
low = tmp_path / "personal"
|
||||
_ = _write_skill(high, "shared", description="Team copy")
|
||||
_ = _write_skill(low, "shared", description="Personal copy")
|
||||
with _bound(tmp_path, high, low):
|
||||
out = await skill_list.handler()
|
||||
assert "Team copy" in out
|
||||
assert "Personal copy" not in out
|
||||
assert "shadowed: shared — config-user wins (also config-user)" in out
|
||||
|
||||
|
||||
async def test_skill_list_shows_notes(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
directory = root / "no-desc"
|
||||
directory.mkdir(parents=True)
|
||||
_ = (directory / SKILL_FILE).write_text("# Heading\n\nBody paragraph.\n", encoding="utf-8")
|
||||
with _bound(tmp_path, root):
|
||||
out = await skill_list.handler()
|
||||
assert "notes: no-desc: no frontmatter description; using the first paragraph" in out
|
||||
|
||||
|
||||
async def test_skill_read_defaults_to_skill_md(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
_ = _write_skill(root, "acme", body="Step one.")
|
||||
with _bound(tmp_path, root):
|
||||
out = await skill_read.handler("acme")
|
||||
assert out.startswith("L1-")
|
||||
assert "Step one." in out
|
||||
|
||||
|
||||
async def test_skill_read_bundled_file_with_line_numbers(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
directory = _write_skill(root, "acme")
|
||||
(directory / "scripts").mkdir()
|
||||
_ = (directory / "scripts" / "run.sh").write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
|
||||
with _bound(tmp_path, root):
|
||||
out = await skill_read.handler("acme", "scripts/run.sh", with_lineno=True)
|
||||
assert " 1|#!/bin/sh" in out
|
||||
|
||||
|
||||
async def test_skill_read_refuses_to_escape_the_skill(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
_ = _write_skill(root, "acme")
|
||||
_ = (tmp_path / "outside.txt").write_text("not yours\n", encoding="utf-8")
|
||||
with _bound(tmp_path, root):
|
||||
assert await skill_read.handler("acme", "../outside.txt") == (
|
||||
"error: file '../outside.txt' is outside skill 'acme'"
|
||||
)
|
||||
assert await skill_read.handler("acme", "/etc/hostname") == (
|
||||
"error: file '/etc/hostname' is outside skill 'acme'"
|
||||
)
|
||||
|
||||
|
||||
async def test_skill_read_unknown_skill_and_missing_file(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
_ = _write_skill(root, "acme")
|
||||
with _bound(tmp_path, root):
|
||||
assert (
|
||||
await skill_read.handler("nope")
|
||||
== "error: unknown skill 'nope'; call skill_list to see the discovered skills"
|
||||
)
|
||||
assert await skill_read.handler("acme", "missing.md") == "error: no such file in skill 'acme': missing.md"
|
||||
|
||||
|
||||
async def test_skill_read_truncates_with_a_continuation_token(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
_ = _write_skill(root, "acme", body="x" * 500)
|
||||
with _bound(tmp_path, root):
|
||||
out = await skill_read.handler("acme", max_chars=120)
|
||||
assert "truncate_token=" in out
|
||||
|
||||
|
||||
async def test_skill_search_attributes_hits_to_their_skill(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
_ = _write_skill(root, "alpha", body="Use pdftotext for extraction.")
|
||||
beta = _write_skill(root, "beta", body="No match in the body.")
|
||||
_ = (beta / "notes.md").write_text("pdftotext again\n", encoding="utf-8")
|
||||
with _bound(tmp_path, root):
|
||||
out = await skill_search.handler("pdftotext")
|
||||
assert "alpha/SKILL.md:" in out
|
||||
assert "beta/notes.md:" in out
|
||||
|
||||
|
||||
async def test_skill_search_single_skill_and_errors(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
_ = _write_skill(root, "alpha", body="Use pdftotext.")
|
||||
_ = _write_skill(root, "beta", body="Nothing to see.")
|
||||
with _bound(tmp_path, root):
|
||||
assert await skill_search.handler("pdftotext", skill="beta") == "(no matches)"
|
||||
assert (await skill_search.handler("pdftotext", skill="nope")).startswith("error: unknown skill")
|
||||
assert (await skill_search.handler("[")).startswith("error: invalid regex")
|
||||
|
||||
|
||||
async def test_skill_search_inside_the_workspace_rebases_the_hit(tmp_path: Path) -> None:
|
||||
"""A project-scope skill root lives inside the workspace: one path, once.
|
||||
|
||||
The search reports in-workspace files relative to the workspace root and outside
|
||||
ones absolutely, so the skill name must never be glued onto a path that
|
||||
already carries the root.
|
||||
"""
|
||||
root = tmp_path / "project" / ".claude" / "skills"
|
||||
_ = _write_skill(root, "alpha", body="Use pdftotext here.")
|
||||
with _bound(tmp_path, root):
|
||||
out = await skill_search.handler("pdftotext")
|
||||
assert out.count("alpha/SKILL.md:") == 1
|
||||
assert "project/.claude/skills/alpha" not in out
|
||||
assert "alpha/alpha/SKILL.md" not in out
|
||||
|
||||
|
||||
async def test_skill_search_without_skills(tmp_path: Path) -> None:
|
||||
empty = tmp_path / "empty"
|
||||
empty.mkdir()
|
||||
with _bound(tmp_path, empty):
|
||||
assert await skill_search.handler("anything") == "(no skills to search)"
|
||||
|
||||
|
||||
async def test_skill_search_respects_max_matches(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
_ = _write_skill(root, "alpha", body="hit\nhit\nhit\nhit\n")
|
||||
with _bound(tmp_path, root):
|
||||
out = await skill_search.handler("hit", max_matches=2)
|
||||
assert out.count("alpha/SKILL.md:") == 2
|
||||
assert out.endswith("...[truncated at 2 matches]")
|
||||
|
||||
|
||||
def test_format_skill_table_lists_roots_in_priority_order(tmp_path: Path) -> None:
|
||||
root = tmp_path / "team"
|
||||
_ = _write_skill(root, "acme")
|
||||
store = SkillStore(
|
||||
build_roots(SkillsConfig(paths=[str(root)], discover=[]), workspace=tmp_path, user_config_dir=tmp_path)
|
||||
)
|
||||
table = format_skill_table(store)
|
||||
assert "skills: 1" in table
|
||||
assert "acme [config-user]" in table
|
||||
|
||||
|
||||
def test_skill_tool_names_cover_the_group() -> None:
|
||||
expected = ("skill_list", "skill_read", "skill_search", "skill_create", "skill_edit")
|
||||
for name in expected:
|
||||
assert name in SKILL_TOOL_NAMES
|
||||
assert len(SKILL_TOOL_NAMES) == len(expected)
|
||||
|
||||
|
||||
async def test_skill_create_writes_skill_md_and_bundled_files(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
|
||||
out = await skill_create.handler(
|
||||
"acme-deploy",
|
||||
"Deploy the acme stack",
|
||||
"# Steps\n\n1. Build.\n",
|
||||
files={"scripts/deploy.sh": "#!/bin/sh\necho deploy\n"},
|
||||
)
|
||||
assert out.startswith("created skill 'acme-deploy' at ")
|
||||
assert (root / "acme-deploy" / SKILL_FILE).is_file()
|
||||
assert (root / "acme-deploy" / "scripts" / "deploy.sh").read_text(encoding="utf-8").startswith("#!/bin/sh")
|
||||
# The new skill is visible right away, without a restart.
|
||||
assert "acme-deploy [config-user]" in await skill_list.handler()
|
||||
read = await skill_read.handler("acme-deploy")
|
||||
assert "description: Deploy the acme stack" in read
|
||||
assert "1. Build." in read
|
||||
|
||||
|
||||
async def test_skill_create_quotes_a_frontmatter_description_that_needs_it(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
|
||||
_ = await skill_create.handler("quoted", "Use: colons, {braces}", "Body.")
|
||||
skill = SkillStore(
|
||||
build_roots(SkillsConfig(paths=[str(root)], discover=[]), workspace=tmp_path, user_config_dir=tmp_path)
|
||||
).skills[0]
|
||||
assert skill.description == "Use: colons, {braces}"
|
||||
|
||||
|
||||
async def test_skill_create_rejects_a_bad_name_and_a_taken_one(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
_ = _write_skill(root, "taken")
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
|
||||
assert (await skill_create.handler("Not-A-Name", "d", "b")).startswith("error: skill name")
|
||||
assert "already exists" in await skill_create.handler("taken", "d", "b")
|
||||
assert (await skill_create.handler("ok-name", "d", "b")).startswith("created skill")
|
||||
|
||||
|
||||
async def test_skill_create_rejects_a_bundled_file_outside_the_skill(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
|
||||
out = await skill_create.handler("acme", "d", "b", files={"../escape.sh": "x"})
|
||||
assert out == "error: bundled file '../escape.sh' is outside skill 'acme'"
|
||||
assert not (tmp_path / "escape.sh").exists()
|
||||
|
||||
|
||||
async def test_skill_write_needs_a_grant(tmp_path: Path) -> None:
|
||||
"""Without a hook the write is denied, and the message says how to open it."""
|
||||
root = _write_root(tmp_path)
|
||||
with _bound(tmp_path, root):
|
||||
out = await skill_create.handler("acme", "d", "b")
|
||||
assert out == (
|
||||
"error: writing skills needs a grant (no confirm hook is installed; denied); "
|
||||
"set [skills].allow_write = true to allow skill writes"
|
||||
)
|
||||
assert not (root / "acme").exists()
|
||||
|
||||
|
||||
async def test_skill_write_prompt_denial_is_reported(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
_ = _write_skill(root, "acme", body="Before.")
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(confirm=_deny, timeout=3.0)):
|
||||
out = await skill_edit.handler("acme", old_string="Before.", new_string="After.")
|
||||
assert out.startswith("error: skill write to ")
|
||||
assert out.endswith("denied (declined or timed out after 3s)")
|
||||
assert "After." not in (root / "acme" / SKILL_FILE).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
async def test_skill_write_confirm_sees_the_action_and_the_skill(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
seen: list[tuple[Path, str, str, float]] = []
|
||||
|
||||
def hook(target: Path, action: str, detail: str, timeout: float) -> bool:
|
||||
seen.append((target, action, detail, timeout))
|
||||
return True
|
||||
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(confirm=hook, timeout=5.0)):
|
||||
_ = await skill_create.handler("acme", "d", "b")
|
||||
assert seen == [(root / "acme", "create", f"create skill 'acme' at {root / 'acme'} (SKILL.md)", 5.0)]
|
||||
|
||||
|
||||
async def test_skill_write_session_grant_is_asked_once(tmp_path: Path) -> None:
|
||||
"""A 'session' answer covers the same skill for the rest of the chat."""
|
||||
root = _write_root(tmp_path)
|
||||
calls: list[Path] = []
|
||||
|
||||
def hook(target: Path, action: str, detail: str, timeout: float) -> SkillWriteDecision:
|
||||
calls.append(target)
|
||||
return SkillWriteDecision(allow=True, session=True)
|
||||
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(confirm=hook)):
|
||||
_ = await skill_create.handler("acme", "d", "b")
|
||||
_ = await skill_edit.handler("acme", append="\nMore.\n")
|
||||
_ = await skill_edit.handler("acme", file="notes.md", new_string="note\n")
|
||||
# One prompt for the skill directory, covering SKILL.md and later files.
|
||||
assert calls == [root / "acme"]
|
||||
assert (root / "acme" / "notes.md").read_text(encoding="utf-8") == "note\n"
|
||||
|
||||
|
||||
async def test_skill_write_denylist_wins_over_a_grant(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
|
||||
set_path_denylist(["acme"])
|
||||
try:
|
||||
out = await skill_create.handler("acme", "d", "b")
|
||||
finally:
|
||||
set_path_denylist(())
|
||||
assert out.startswith("error: path denied by policy (matched 'acme')")
|
||||
assert not (root / "acme").exists()
|
||||
|
||||
|
||||
async def test_skill_edit_forms(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
_ = _write_skill(root, "acme", body="Line one.\nLine two.")
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
|
||||
out = await skill_edit.handler("acme", old_string="Line two.", new_string="Line two, edited.")
|
||||
assert out.startswith("updated skill 'acme' (SKILL.md: replaced 1 of 1 occurrence(s))")
|
||||
|
||||
out = await skill_edit.handler("acme", append="\nAppended tail.")
|
||||
assert "appended 15 characters" in out
|
||||
|
||||
out = await skill_edit.handler("acme", file="scripts/run.sh", new_string="#!/bin/sh\n")
|
||||
assert "rewrote 10 characters" in out
|
||||
assert (root / "acme" / "scripts" / "run.sh").is_file()
|
||||
text = (root / "acme" / SKILL_FILE).read_text(encoding="utf-8")
|
||||
assert "Line two, edited." in text
|
||||
assert text.endswith("Appended tail.")
|
||||
|
||||
|
||||
async def test_skill_edit_replace_all_and_missing_match(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
directory = _write_skill(root, "acme", body="hit\nhit\nhit")
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
|
||||
out = await skill_edit.handler("acme", old_string="hit", new_string="done", replace_all=True)
|
||||
assert "replaced 3 of 3 occurrence(s)" in out
|
||||
missing = await skill_edit.handler("acme", old_string="nope", new_string="x")
|
||||
assert missing == "error: old_string not found in SKILL.md of skill 'acme'"
|
||||
assert (directory / SKILL_FILE).read_text(encoding="utf-8").count("done") == 3
|
||||
|
||||
|
||||
async def test_skill_edit_rejects_conflicting_forms(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
_ = _write_skill(root, "acme")
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
|
||||
assert (await skill_edit.handler("acme")).startswith("error: nothing to write")
|
||||
assert (await skill_edit.handler("acme", old_string="a", new_string="b", append="c")).startswith(
|
||||
"error: use either"
|
||||
)
|
||||
assert (await skill_edit.handler("acme", file="nope.md", append="x")) == (
|
||||
"error: no such file in skill 'acme': nope.md"
|
||||
)
|
||||
assert (await skill_edit.handler("acme", file="../x.md", new_string="x")).startswith("error: file")
|
||||
|
||||
|
||||
async def test_skill_edit_notes_a_rewritten_frontmatter(tmp_path: Path) -> None:
|
||||
"""A SKILL.md that loses its description (or breaks) is reported, not repaired."""
|
||||
root = _write_root(tmp_path)
|
||||
_ = _write_skill(root, "acme")
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
|
||||
dropped = await skill_edit.handler("acme", new_string="Only a body, no frontmatter.\n")
|
||||
broken = await skill_edit.handler("acme", new_string="---\nname: acme\nbroken line\n---\n\nBody.\n")
|
||||
assert dropped.endswith("notes: SKILL.md has no frontmatter description; the first paragraph will be used")
|
||||
assert "notes: SKILL.md frontmatter not parsed" in broken
|
||||
|
||||
|
||||
async def test_skill_tools_reject_writes_when_no_session_is_bound(tmp_path: Path) -> None:
|
||||
"""A host without session state still denies instead of crashing on the grant."""
|
||||
root = _write_root(tmp_path)
|
||||
_ = _write_skill(root, "acme", body="Before.")
|
||||
with _bound(tmp_path, root, session=False, policy=SkillWritePolicy(confirm=_allow)):
|
||||
out = await skill_edit.handler("acme", old_string="Before.", new_string="After.")
|
||||
assert out.startswith("updated skill 'acme'")
|
||||
Reference in New Issue
Block a user