32 Commits

Author SHA1 Message Date
NCBM 36156043ca release: plyngent 0.7.0 2026-09-22 23:35:03 +08:00
NCBM 2cc5222dd3 docs: use regex_files tool name in docs 2026-09-22 23:23:11 +08:00
NCBM 0343e58dd9 core/config: point default playbook and reminder at regex_files 2026-09-22 23:22:41 +08:00
NCBM 7127190b9a cli/display: label regex_files lines with Search 2026-09-22 23:22:29 +08:00
NCBM de03e5932b core/tools: rename grep_files to regex_files (regex pattern) 2026-09-22 23:22:13 +08:00
NCBM 76933b044c docs: describe grep_files pattern and path shapes 2026-09-22 23:17:53 +08:00
NCBM 2a6130495c core/config: steer the playbook away from shell/interpreter commands 2026-09-22 23:13:41 +08:00
NCBM 9b365403d7 cli/history: print a row's reasoning before its answer
A reasoning trace was appended after the body it produced, which read as if the
answer had come first and the thinking had followed it. It now leads the row,
under its own `reasoning:` label with a blank line before the answer - the order
the live stream uses. The trace only prints where it already did: a turn's ends
(the default view) and -vv; previews stay silent.
2026-09-22 23:05:59 +08:00
NCBM 20fc493c47 tools/skills: rebase search hits from either path form
A skill root can live inside the workspace (the project-scope .claude/skills),
and grep reports those hits relative to the workspace root while outside ones
come back absolute. The attribution only stripped an absolute skill prefix, so
an in-workspace root produced alpha/alpha/SKILL.md - and, worse, a path that no
longer started with the skill name when the root itself was the workspace.
Normalizing the hit to an absolute path before relativizing to the skill covers
both, and the hit line is now matched with a pattern instead of a colon split so
a Windows drive letter cannot split it wrongly.

Found by running the REPL against a workspace-local skill root; the tool tests
only covered roots outside the workspace, so they get that case now.
2026-09-22 22:59:57 +08:00
NCBM b967a000dc docs: document skills
README and the example config cover the [skills] section, the five tools, and
the read/write split; AGENTS.md gains the skills/ package, the tool family, the
config fields, and the /skills + /status surface; architecture.md lists the
package. doc/skills.md is the author guide: layout, the frontmatter subset and
how a broken one degrades, roots and shadowing, what the agent sees, the
grant story for reads and writes, and the warning that a skill is data —
third-party skill directories are untrusted input.
2026-09-22 22:58:09 +08:00
NCBM 90174340d7 cli/skills: expose skills to the session
ReplState builds the store from [skills] against the current workspace and
publishes its roots as static read-only roots, so read_file/grep_files see a
skill directory while writing stays denied; the roots are rebuilt when the
workspace changes, since the project-scope directories hang off it. Discovered
skills are folded into the system prompt the way MCP instructions are, with the
catalog cap and its 'use skill_search' hint, and only when tools are on and
injection is not switched off; [skills].enabled = false drops the tools from the
registry entirely.

The write policy is installed with the CLI confirm: once, session, or no, timed
with a default of no and independent of YOLO, because a skill directory is not a
grant. A library host that installs no hook keeps the deny default. /skills
lists, searches, reads, and reloads; /status counts what was found.
2026-09-22 22:55:24 +08:00
NCBM c7441d4a43 tools/skills: create and modify skills behind a grant
skill_create writes a SKILL.md (name, description, body) plus whatever bundled
files the model lists, and skill_edit changes one file of an existing skill
either by literal replacement, by appending, or by rewriting it. Both resolve
every path inside the skill directory first, so a bad argument is answered
before anything is written and before the human is asked anything; a rewritten
SKILL.md is re-parsed so a skill that lost its description (or its frontmatter)
says so instead of failing quietly. The store is reloaded after a write, which
makes a new skill visible to the very next skill_list.

Writing is a host policy, not a directory-access grant: the denylist wins
first, then a session grant, then [skills].allow_write, then the host's confirm
hook — asked once per skill directory, so one answer covers its files. A host
without a hook denies, which keeps the library default safe; the confirm can
also answer 'session' to cover the rest of the chat.
2026-09-22 22:46:21 +08:00
NCBM 1d05da62c2 tools/skills: list, read, and search skills
skill_list shows what was discovered (name, source root, directory, description)
together with the copies a name collision shadowed and the notes from repaired
frontmatter, so a third-party skill never disappears quietly. skill_read
addresses a skill by name and defaults to its SKILL.md, reusing read_file for
ranges, line numbers, and the continuation token; a bundled file is resolved
inside the skill directory only, symlinks included. skill_search greps every
visible skill (or one) and rebases each hit on its skill, which makes a match
attributable without absolute paths.

Reads need no grant: the host registers the roots as static read-only roots on
the workspace policy, so read_file/grep_files see skill directories too while
write access stays denied — skill writes will go through their own confirm.
Grep used to drop every file outside the primary workspace root, which also hid
hits under a granted directory; those now come back as absolute paths.
2026-09-22 22:42:45 +08:00
NCBM a9f58428bf skills: discover roots and parse SKILL.md frontmatter
A skill is a directory with a SKILL.md; roots are ordered by priority (an
explicit path, then our own user directory, then the directories other
harnesses install into, with the project-scope one behind its own switch). The
first copy of a name wins and the ones it shadows stay listed for reporting
instead of vanishing.

The frontmatter reader covers the subset those files actually use — scalars,
booleans, ints, inline and block lists, | and > blocks, folded plain scalars —
so no YAML dependency is needed; a nested mapping is skipped and a structurally
broken block degrades to "no metadata" with a note, keeping a third-party skill
listable rather than dropping it. Metadata is cached against the directory list
and its SKILL.md timestamps, so a listing is cheap yet notices an edit.
2026-09-22 22:37:42 +08:00
NCBM bbda658eab skills/config: add the [skills] section
Skills are discovered from user-level directories by default: an explicit
``paths`` list wins, then the built-in sources (our own user config dir, and
other harnesses' skill dirs) with the project-scope dirs behind their own
switch. A name found twice shadows the lower-priority copy without hiding it.

``inject_catalog`` / ``max_catalog_skills`` bound what reaches the system
prompt, ``enabled = false`` registers nothing, and ``allow_write`` is the
standing grant for editing skills (default: ask every time). A malformed
section degrades to the defaults, like [agent] and [mcp].
2026-09-22 22:26:50 +08:00
NCBM ec228e9219 docs: document the full pretty-tool coverage
Every builtin tool now prints the in-progress prefix (previously only the ones
with a renderer were named), MCP tools share one generic line, and plugin tools
keep the [tool] fallback. The parallel-batch fallback erases only its own line,
so the streamed line above it survives.
2026-09-22 22:25:43 +08:00
NCBM d6c4aa0d4f cli/display: prettify the remaining tools
PTY, human-prompt, temp-dir, truncate, and the rest of the VCS/todo family still
printed raw "[tool] name(args)" lines while every other tool had a syntax. Each
one now has a prefix that appears as the call starts and an outcome parsed from
its own result shape: session ids and alive/exit state for PTY, answered/
selected/count for the ask family, granted or denied for access requests, the
new path for a temp dir, changed files with line counts for vcs_diff, and the
rendered stack for the remaining todo commands. ask_into_pty reports only that
the answer was written.

Resolution is no longer a dict lookup: namespaced MCP tools are built from
their ``mcp__<server>__<tool>`` name so a newly connected server gets the same
line without registration, and the resolved renderer is carried from call to
result instead of being looked up twice. Plugin tools and custom calls keep the
whole-line fallback. The shared key=value parser and the ranged-read detail are
reused by run_argv, fetch, read_file, and get_truncated rather than copied.

Tests cover every added line (including the error paths), the MCP naming rule,
and an invariant that each builtin tool resolves to a renderer.
2026-09-22 22:25:34 +08:00
NCBM 221d5d5b68 cli/display: keep the line above an erased tool prefix
Erasing the in-flight prefix reused a helper that cleared one row more than
requested, so a parallel tool batch also erased the line above the prefix -
the streamed reasoning text that sits there when a model reasons and then
calls tools. The helper now clears exactly the requested rows and takes
explicit control of where the cursor lands; the parallel fallback erases its
own line and lets the leading newline of the whole-line write reuse it.

Row counts are computed from physical terminal rows instead of logical
newlines, so an assistant segment wider than the terminal is fully erased
before the markdown re-render rather than leaving a stray copy behind.

Tests: a byte-level regression for reasoning before a parallel batch, unit
coverage for the row-counting and erase primitives, and a wrapped-line flush.
2026-09-22 22:13:24 +08:00
NCBM d697ecdaf6 docs: note that /history keeps a turn's ends expanded
The user message and the final answer print in full at every verbosity level;
only --preview collapses them.
2026-09-22 21:15:31 +08:00
NCBM 836ac8d6f8 cli/history: never collapse a turn's ends
The mixed policy previewed the user row, so a long prompt came back truncated
even though it is half of what the turn is about. Both ends - the human's
message and the model's answer - now print in full at every verbosity level;
--preview remains the explicit one-line skimmer that collapses them too.

Tests cover the ends staying full in the default and -v views (and the rounds
between them staying one-liners) plus a long-prompt regression.
2026-09-22 21:15:07 +08:00
NCBM 72d323d680 docs: document the turn-oriented /history
/history now works in turns (default: the last turn's user message and final
answer), with -v/-vv for intermediate rows and full bodies, and display-number
addressing (N / last [N] / --message N) that never exposes database rows.
2026-09-22 21:02:12 +08:00
NCBM 76e5b8ac0f cli/history: group /history by turn
/history counted raw message rows, so /history 1 printed whatever row happened
to be last (usually a tool result in a tool-heavy session) and "one row means
full body" was a proxy for "show me that one thing".

A turn is what a human reviews: /history now prints the last turn's two ends
(the user message and the model's answer, markdown for the latter), -v prints
every row of the selected turn(s), -vv prints full bodies plus the local system
row, and --preview collapses every row to one line. N addresses turn N,
last [N] the last N turns, --message N a single row.

Numbering is a display ordinal over conversation rows (local-only rows print as
local) and deliberately never a database row number - seq is an implementation
detail that shifts with /clear. Grouping and rendering live in the new
cli/transcript.py.
2026-09-22 21:01:49 +08:00
NCBM bbed7c87cd docs: document SIGINT routing and MCP process-group isolation
Ctrl+C now cancels the turn (or the auto-retry countdown), cancels a poll-based
prompt read such as wait's, and is ignored with a hint for off-loop readline
prompts; it never exits the REPL and never reaches MCP servers, which spawn in
their own session. Also drop ask_into_pty's stale "Ctrl+C cancels" claim: that
prompt is readline-based and empty input is its cancel.
2026-09-22 19:44:31 +08:00
NCBM dfd45a1d57 cli/display: show a cancelled wait distinctly
The wait line rendered every non-error/non-slept outcome as "(disturbed)", so a
Ctrl+C cancellation of the wait prompt looked like the human disturbed it. Print
"(cancelled)" for the tool's "cancelled by user" result.
2026-09-22 19:44:19 +08:00
NCBM 27bd7b46f7 prompting: let Ctrl+C cancel poll-based prompt reads
The wait tool's prompt runs off-loop on a worker thread, but its read polls
select/msvcrt, so it can act on a cancel: the SIGINT target now sets a flag the
reader checks between polls and raises PromptCancelledError with, which the tool
reports as "cancelled by user" while the turn continues. run_prompt_async /
run_cancellable_prompt_async hand the matching hook to the CLI guard; a stale
cancel never leaks into the next prompt read.
2026-09-22 19:28:03 +08:00
NCBM 6ec7b4de12 cli/interrupt: route SIGINT through a session handler
A SIGINT delivered while the event loop is parked in its selector is raised in
the loop's own frame: it escapes run_until_complete, the Runner cancels the main
task, and the REPL is left half-shut-down (no prompt, and the next line the
human types is eaten by an abandoned prompt reader) until the following Ctrl+C
kills the process. Off-loop prompts (ask_user_* / wait / tool confirms run in a
worker thread via asyncio.to_thread) and the auto-retry countdown are the usual
windows.

Keep one asyncio-level SIGINT handler installed for the whole session instead:
it cancels the innermost registered target (run_cancellable registers the turn
task, sleep_cancellable the retry countdown, off_loop_prompt the waiting prompt)
and never raises. Reads that block on the main thread inside a coroutine (the
REPL entry prompt, synchronous click prompts) suspend the router and take SIGINT
as a KeyboardInterrupt. Off-loop readline prompts cannot be interrupted without
abandoning their reader thread on stdin, so they ignore SIGINT with a one-line
hint.
2026-09-22 19:16:05 +08:00
NCBM 0790b5dd9f runtime/mcp: spawn servers in their own process group
A terminal Ctrl+C is delivered 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 until
/mcp reconnect. start_new_session (POSIX) / CREATE_NEW_PROCESS_GROUP
(Windows) puts each server in its own session/group instead.

Tests assert the spawned server's process group and session differ from the
CLI's (they used to be the same group).
2026-09-19 11:05:33 +08:00
NCBM a9f77a3b96 docs: document wait shape checks and missing-argument errors 2026-09-19 09:51:25 +08:00
NCBM f94d9a6f49 agent/tools: report missing required arguments before running 2026-09-19 09:50:13 +08:00
NCBM cb35e3b105 tools/chat: shape-check the wait duration and reason 2026-09-19 09:44:43 +08:00
NCBM 9224241841 docs: document ask-family argument shape checks 2026-09-19 09:39:08 +08:00
NCBM 86d90bfeb1 tools/chat: return shape errors for mis-typed ask-family arguments 2026-09-19 09:37:25 +08:00
56 changed files with 5692 additions and 683 deletions
+16 -11
View File
@@ -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.
+23 -4
View File
@@ -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
View File
@@ -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.
+18
View File
@@ -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
View File
@@ -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.
Generated
+112 -91
View File
@@ -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
View File
@@ -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.
+29
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+58 -5
View File
@@ -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)
+13 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+78 -1
View File
@@ -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)
+414
View File
@@ -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)
+2
View File
@@ -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
+42 -1
View File
@@ -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.
+18
View File
@@ -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
View File
@@ -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:
+18
View File
@@ -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}"
+25
View File
@@ -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",
]
+329
View File
@@ -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() + "…"
+196
View File
@@ -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
+9 -1
View File
@@ -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
+4
View File
@@ -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,
+7
View File
@@ -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:
+14
View File
@@ -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:
+22 -3
View File
@@ -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:
+74
View File
@@ -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}"
+39 -17
View File
@@ -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)
+6
View File
@@ -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))
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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:
+589
View File
@@ -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)
+32 -1
View File
@@ -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)
+1 -1
View File
@@ -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"}'),
),
],
),
+40
View File
@@ -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
View File
@@ -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],
+358
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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
+2 -66
View File
@@ -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
+290
View File
@@ -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
+69
View File
@@ -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
+57
View File
@@ -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."""
+21
View File
@@ -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()
+195
View File
@@ -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"
+114
View File
@@ -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")
+15 -2
View File
@@ -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",
}
+184 -1
View File
@@ -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
+419
View File
@@ -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'")