46 Commits

Author SHA1 Message Date
NCBM ef8d74a92a release: plyngent 0.6.0 2026-09-09 22:58:03 +08:00
NCBM 3fa05a86c6 docs: document limit auto-continue (yyy) 2026-09-09 22:48:16 +08:00
NCBM 18cbcf6835 cli/config: --auto-continue flag and [agent] auto_continue_limits 2026-09-09 22:47:39 +08:00
NCBM aba3765def cli: reset auto-continue at turn boundaries 2026-09-09 22:46:19 +08:00
NCBM 685370d3cb cli/limits: yyy turn-scoped auto-continue for limit prompts 2026-09-09 22:44:20 +08:00
NCBM af1e4a4770 docs: document directory access grants 2026-09-09 22:24:44 +08:00
NCBM a1f106a5e2 cli: directory access confirm, /grants, and session grants 2026-09-09 22:23:25 +08:00
NCBM 22f52a0bd2 config: [agent] allow_paths static pre-allow 2026-09-09 22:19:23 +08:00
NCBM d17c80b07e memory: persist session access grants 2026-09-09 22:17:34 +08:00
NCBM 49d1e08ebe tools: request_directory_access and grant stores 2026-09-09 22:16:22 +08:00
NCBM ba021cb138 tools/workspace: mode-aware path grants and escape hint 2026-09-09 22:11:50 +08:00
NCBM 66c58cf156 release: plyngent 0.5.0 2026-09-08 14:30:44 +08:00
NCBM a1455989f2 docs: document MCP initialize instructions folding 2026-09-08 13:09:41 +08:00
NCBM 4f142f1db9 cli: fold MCP initialize instructions into agent prompt and /mcp 2026-09-08 13:09:32 +08:00
NCBM 5f28a49bc1 config: add [agent] mcp_instructions and variadic system compose 2026-09-08 13:09:23 +08:00
NCBM a4baf38a52 runtime: capture MCP initialize instructions per connection 2026-09-08 13:09:14 +08:00
NCBM c638f6a5da docs: rename CLAUDE.md to AGENTS.md 2026-09-08 12:59:26 +08:00
NCBM ede614aa9e gitignore: add .codegraph index directory 2026-09-08 12:58:13 +08:00
NCBM 41ba0f9dda docs: document MCP server config, runtime, and /mcp command 2026-09-08 12:19:54 +08:00
NCBM 4916494e44 cli: start MCP servers around chat and add /mcp slash command 2026-09-08 12:18:35 +08:00
NCBM d603615d6a tools: register MCP server tools in the catalog (mcp source, purge) 2026-09-08 12:06:34 +08:00
NCBM 7967095dbc runtime: MCP stdio client and per-process connection manager 2026-09-08 12:02:16 +08:00
NCBM dd70d02491 config: [mcp] section with server definitions and disable gate 2026-09-08 11:53:19 +08:00
NCBM 7c377a1a53 core/tools: two-line wait prompt with reason input and colors 2026-08-17 17:42:32 +08:00
NCBM 0a0f05ad14 cli: restore line range in read_file pretty summary 2026-08-17 17:33:55 +08:00
NCBM ab8828f7b6 release: plyngent 0.4.1 2026-08-15 18:45:09 +08:00
NCBM 95a48873c1 cli: pretty printed summary for wait tool 2026-08-15 18:42:31 +08:00
NCBM e27effcefe cli: pretty printed summaries for vcs_status and vcs_log 2026-08-15 18:37:12 +08:00
NCBM 8e8a10a05e cli: reduce pretty summaries to minimal done/error semantics 2026-08-15 18:36:21 +08:00
NCBM 5344a20b67 cli: pretty printed summaries for file mutation tools 2026-08-15 18:09:52 +08:00
NCBM aeae99a496 cli: pretty printed summary for fetch with HTTP status coloring 2026-08-15 18:09:05 +08:00
NCBM 259dd87c43 cli: pretty printed summaries for run_argv and run_argv_batch 2026-08-15 18:08:28 +08:00
NCBM 6f939e52b2 cli: pretty printed summaries for listdir, glob_paths and grep_files 2026-08-15 18:06:36 +08:00
NCBM 41ce98bf42 cli: style pretty tool summaries, verbose shows full results, preview shows first line 2026-08-15 18:05:40 +08:00
NCBM 5436457ac0 core/tools: allow edit_lineno append to an empty file
Appending via edit_lineno required the last line to be read, which made
writing to an empty file impossible (line 0 does not exist). An empty
file has nothing to clobber, so the append needs no prior read.
2026-08-15 17:41:06 +08:00
NCBM 2cf66edb60 core/tools: edit results preview the old content
Wrong-target edits (the test_run_aside pass clobber, the edit_replace
mangling) were silent: the success message reported counts only, so the
model had no signal it replaced the wrong thing until later.

edit_lineno now reports the first removed line ("first: 'pass'") and
edit_replace reports the matched pattern ('old' -> 'new'), giving the
model an immediate self-correction signal.
2026-08-15 17:40:26 +08:00
NCBM 38671482f2 core/tools: edit_lineno unread error names the read window
The boundary overguard (read offset=50 shows lines 51+, then editing
line 50 fails) forced blind re-reads. The error now reports the read
window ("this turn read lines 51-86") and advises reading with a margin
around the target range; read_file's description says the same.
2026-08-15 17:38:58 +08:00
NCBM c9815e32bb core/tools: mtime freshness replaces per-write lineno invalidation
edit_lineno now rejects edits when the file changed since the numbered
read (mtime recorded at read time), so stale line numbers are caught
even when the mutation came from run_argv / an external process — the
class the explicit invalidations could never cover.

This lets write_file / edit_replace / edit_lineno drop their explicit
invalidate calls (the write itself changes the mtime, and the error is
now the accurate "changed since it was read" instead of a misleading
"not read"). copy/move keep explicit invalidation because shutil
preserves the source mtime, which the freshness check cannot see.
2026-08-15 17:38:10 +08:00
NCBM d914590677 core/tools: numbered read continuation via get_truncated
Two fixes for the truncated numbered-read flow:

- read_file(with_lineno) tokens were rebased onto numbered-body chars
  (each line carries an N| prefix), so get_truncated over-advanced into
  raw text and silently skipped lines. Tokens now advance by raw chars
  of the complete lines shown and carry a numbered flag.
- get_truncated resumes numbered tokens as numbered lines (via
  read_file with_lineno), marking them readable for edit_lineno, so
  read -> get_truncated -> edit_lineno works end-to-end without gaps.
2026-08-15 17:35:57 +08:00
NCBM 132ac520ec core/tools: mark only displayed lines read on truncated read_file
read_file(with_lineno=true) marked the whole requested slice before
applying the max_chars cap, so a truncated read silently unlocked every
line of the file for edit_lineno even though the model never saw them.
Mark only the complete numbered lines actually returned (excluding the
[Truncated] marker); the unseen tail stays unread.
2026-08-15 17:31:43 +08:00
NCBM 32456c9f47 docs: tree markdown default and flat/decorated formats 2026-08-15 17:10:21 +08:00
NCBM b5014cd3e7 cli: pretty tree tool summary like read_file
Tool results for tree render as a single line: * Tree 'path' (N dirs,
M files, depth D) for markdown/decorated, dropping the depth for flat.
Counts and depth are parsed from the rendered output per format.
2026-08-15 17:09:57 +08:00
NCBM 4fa05e2456 core/tools: tree format markdown|flat|decorated, markdown default
The model picks the representation per call via the format param (no
user config). markdown (default) is a nested bullet list: ASCII-only,
cheapest tokens, indentation-based hierarchy the model reads reliably.
flat lists one relative path per line (dirs with trailing /, empty dirs
included) with max_entries capping the total lines, so paths are directly
usable. decorated keeps the classic box-drawing tree for humans.
2026-08-15 17:06:12 +08:00
NCBM ae93128e02 docs: document READ_ONLY attribute and /btw --tools modes 2026-08-15 16:53:06 +08:00
NCBM b16b2547b8 cli: /btw --tools [no|read|full] with read-only default
/btw now defaults to --tools=read: the side turn gets only READ_ONLY
tools under a read-only context (fetch restricted to GET), with the main
session view so read-only reads (todo_list) see real state. --tools=full
keeps the previous whole-registry clone with a fresh session; --tools=no
turns tools off. When tools are off, the default read silently falls back
to no tools, while an explicit --tools read|full errors as before.
2026-08-15 16:52:02 +08:00
NCBM ddca6e23d3 core/tools: add READ_ONLY tool attribute
New ToolTag.READ_ONLY marks tools that never mutate host state, plus
ToolRegistry.clone(read_only_only=True) to select just those (for safe
side turns / planning). Builtin read-only set: read_file, get_truncated,
glob_paths, grep_files, listdir, tree, todo_list, wait, ask_user_*,
vcs_* and fetch.

fetch is tagged READ_ONLY but can mutate via method; a new
read_only_context ContextVar makes it accept GET only while set.
2026-08-15 16:46:27 +08:00
76 changed files with 4800 additions and 418 deletions
+3
View File
@@ -214,3 +214,6 @@ __marimo__/
# Streamlit
.streamlit/secrets.toml
# codegraph (local code index)
.codegraph/
+16 -11
View File
@@ -1,4 +1,4 @@
# CLAUDE.md
# AGENTS.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@@ -56,15 +56,17 @@ 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).
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.
### Runtime (`runtime/`)
`create_client(provider)` maps config `Provider` → protocol client by effective preset (model-level `preset`/`url` overrides apply). `normalize_http_timeout` feeds OpenAI-compatible configs and Anthropic. Mapping: `openai` → `OpenAIClient` (agent uses Responses), `openai-compatible` / `deepseek` → chat-completions clients (deepseek with effective `convention = "responses"` → `DeepseekResponsesClient`, `"anthropic"` → `DeepseekAnthropicClient`), `anthropic` → `AnthropicClient`.
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).
### Memory (`memory/`)
Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `ALTER` for new columns), default local user, sessions (bound to `workspace` path; optional `provider_name`/`model`), messages stored as msgspec chat message JSON.
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.
### Agent (`agent/`)
@@ -78,23 +80,25 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
- **`/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), `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), `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/`)
Module-level `@tool` handlers. Call `set_workspace_root()` before use.
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots; path substring denylist; command basename denylist (CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); clearer deny messages.
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range), `write_file`, `listdir`, `tree` (VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; 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=...`).
- **`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=...`).
- **`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.
- 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).
- **`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.
- **`DEFAULT_TOOLS` / catalog**: file + process + vcs + chat + todo + net (`fetch`) for local surface.
- **`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.
### Prompting (`prompting.py`)
@@ -104,8 +108,9 @@ 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), `--stream/--no-stream`, `--quiet`. Root `--log-level`.
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""` … `"""`; `/edit` via `$VISUAL`/`$EDITOR` (blocking only). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack.
- **`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.
- `/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.
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**.
- Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then `/retry`.
- **`plyngent providers`**, **`config path|edit`**. 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.
@@ -135,7 +140,7 @@ Basedpyright `recommended`. Ruff includes `ANN` (private return types `ANN202` i
- G3: multiline `"""` … `"""` input (`cli/input_text.py`); `/edit` via `$EDITOR` (`edit_text_in_editor`)
- G4: `plyngent chat -p/--prompt` (+ non-TTY stdin); exit codes 0/1/2/3; `--yes` / non-interactive confirm deny; `--stream/--no-stream`, `--quiet`
- G5: PTY master FD non-inheritable; `read_pty`/`close_pty` via `to_thread`; `PtyManager.close_all()` on chat exit; `--log-level`; clearer invalid TOML errors; export/status stay secret-free
- G6: README, `doc/plyngent.example.toml`, CLAUDE overview/CLI notes
- G6: README, `doc/plyngent.example.toml`, AGENTS overview/CLI notes
Phase G complete for single-user CLI polish. Next roadmap work is Phase H or optional F (cost/tokenizer).
+18 -3
View File
@@ -147,6 +147,13 @@ access_key_or_token = "sk-..."
# tool_directives = '''### Workspace ...'''
confirm_destructive = true
max_context_tokens = 200000
# Fold MCP server initialize ``instructions`` (usage guidance) into the
# system prompt when tools are on (default true; false ignores server text).
# mcp_instructions = true
# Static out-of-workspace pre-allow (path → read|write|exec); never prompts.
# allow_paths = { "/data/datasets" = "read", "/tmp/build" = "exec" }
# Auto-raise tool/PTY limits without prompting (same as answering yyy).
# auto_continue_limits = false
# Optional plugins (entry-point names); default load none. See doc/plugins.md.
# [plugins]
@@ -157,6 +164,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).
**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:
| Preset | API used by the agent | Notes |
@@ -209,10 +218,13 @@ plyngent chat --session 3
| `--stream` / `--no-stream` | Streaming deltas (default on) |
| `--quiet` | Less status on stderr |
| `--yes` | YOLO on: skip destructive-tool confirms for this process |
| `--auto-continue` | Auto-raise tool/PTY limits without prompting (like answering `yyy`) |
| `--log-level` | On the root CLI: `DEBUG`, `INFO`, `WARNING`, … |
Sessions resume the **most recently updated** session for the current workspace unless you pass `--new` or `--session`. Each session remembers the last **provider** and **model** (restored on resume so you are not re-prompted).
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.
### One-shot (scripts / CI)
```bash
@@ -252,11 +264,13 @@ Type `/help` in the REPL for the live list. Common ones:
| `/yolo [on\|off\|once]` | Soft destructive confirms: sticky skip, off, or next turn only |
| `/retry` | Re-run incomplete last user turn (after error/cancel) |
| `/btw [--tools read\|no\|full] [--fresh]` | Side question without changing the main session (read-only tools by default) |
| `/provider` `/model` | Switch without restarting |
| `/model --persist` | Save current model id into `plyngent.toml` catalog |
| `/models` | List config + remote `GET /models` (always re-fetches) |
| `/models --persist` | Merge remote catalog into TOML for this provider |
| `/todos` | Todo/task stack: list, push, pop, done, clear |
| `/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 |
@@ -266,15 +280,16 @@ User messages are saved immediately. On API error or Ctrl+C, partial assistant/t
- **Workspace** = root for file/process/VCS tools (default cwd).
- **Session** = SQLite chat bound to a workspace path.
- Paths outside the workspace need a **directory-access grant**: the model calls `request_directory_access` (`read` / `write` / `exec`), the human approves at the requested level or another one (timed y/N, default deny; `--yes`/`/yolo` auto-approve as a process-only grant), and approval lasts for the session (`/grants` lists/revokes). Static pre-allow lives under `[agent].allow_paths`. The path denylist always wins, and grants gate path-resolving tools only — they are **not** a sandbox (`run_argv`/PTY reach the whole filesystem).
- Resuming a session from another directory prompts: keep session workspace, rebind to current, or abort.
## Tools (when enabled)
Default registry: file ops (including `tree` with 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), `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=...`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
Safety defaults:
- Paths stay under the workspace; optional `path_denylist` substrings (`tree` also skips denylisted children by default).
- Paths stay under the workspace unless granted (`request_directory_access` / `[agent].allow_paths`); optional `path_denylist` substrings always apply (`tree` also skips denylisted children by default).
- Command basename denylist (e.g. dangerous shells/utilities).
- Destructive tools (delete/move/overwrite) can require confirm (`confirm_destructive`; default deny in non-TTY). Override for the session with `/yolo on|off|once` or startup `--yes` (path/command denylists still apply).
- PTY sessions: caps, idle TTL, output budget; master FD is non-inheritable; sessions closed on chat exit.
@@ -299,7 +314,7 @@ plyngent --log-level INFO chat ...
## Architecture (short)
See [doc/architecture.md](doc/architecture.md) and [CLAUDE.md](CLAUDE.md) for developers.
See [doc/architecture.md](doc/architecture.md) and [AGENTS.md](AGENTS.md) for developers.
- **`lmproto/`** — OpenAI-compatible, OpenAI Responses, Anthropic Messages, DeepSeek msgspec models and async SSE clients
- **`agent/`** — kind-based tool loop (`chat_completions` / `responses` / `messages`), streaming, usage, compact
+2 -2
View File
@@ -27,8 +27,8 @@
- 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; catalog; plugins;
instance/session context and views.
- tools: Workspace file/process/VCS/chat/todo/net 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.
- web: Web service (Phase H; not implemented).
+5
View File
@@ -50,6 +50,11 @@ Notes:
- Optional soft-confirm bits: **`YOLO`** (eligible for YOLO auto-approve),
**`TRUSTABLE`** (grant once per session after approve). Hard denylists are
never YOLO-skipped.
- Optional host-policy bit: **`READ_ONLY`** marks tools that never mutate host
state — they become eligible for read-only contexts such as
`/btw --tools=read` side turns or planning. Tools that only sometimes mutate
(e.g. `fetch` with a non-GET method) may still opt in; the handler must
enforce the constraint via the read-only context flag.
- Docstring becomes the model-facing description; parameter schemas come from
type hints.
- Use `register=False` only for unit tests that build a private registry.
+26
View File
@@ -26,6 +26,22 @@
# [networking]
# ssrf_assume_public_cidrs = ["198.18.0.0/15"]
# 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.
# A server's initialize ``instructions`` (usage guidance) is folded into the
# agent system prompt when tools are on ([agent] mcp_instructions = false
# disables; /mcp previews what each server sent).
# [mcp.servers.docs]
# command = "npx"
# args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
# env = { RUST_LOG = "info" }
# cwd = "/path" # spawn working directory (empty = inherit)
# timeout = 30.0 # per-request timeout (seconds)
# read_only = true # mark every tool READ_ONLY (eligible for /btw --tools=read)
# [mcp]
# disable = ["docs"] # defined but never connected
[agent]
# Persona (omit → built-in coding-agent line). tool_directives is the tool playbook.
# Override with multi-line literals (prefer ''' so nested " is fine):
@@ -42,8 +58,18 @@ max_tool_result_chars = 32000
parallel_tools = true
confirm_destructive = true
path_denylist = ["/secrets/", ".ssh/", ".gnupg/"]
# Static out-of-workspace pre-allow (path → read|write|exec); never prompts.
# The model can also request access at runtime via request_directory_access
# (human approves; /grants lists or revokes). Denylist still applies, and
# grants are a tool-policy gate, not a sandbox (commands reach the FS).
# allow_paths = { "/data/datasets" = "read", "/tmp/build" = "exec" }
# Auto-raise tool/PTY limits without prompting (same as answering yyy).
# auto_continue_limits = false
# Soft context budget in estimated tokens (~4 chars/token when API usage missing).
max_context_tokens = 200000
# Fold connected MCP servers' initialize ``instructions`` (usage guidance)
# into the agent system prompt when tools are on. false = ignore server text.
# mcp_instructions = true
# How open-todo nags are injected into model context:
# developer (default) | user | synthetic_tool | none
# (legacy system is treated as developer)
Generated
+201 -151
View File
@@ -4,7 +4,7 @@
[metadata]
groups = ["default", "dev"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
lock_version = "4.5.1"
content_hash = "sha256:94dc16bf6ed12e2fcd7a109b500e9b2d7e39f224b60472d64ecb1d3436ddbb82"
[[metadata.targets]]
@@ -52,53 +52,109 @@ files = [
[[package]]
name = "charset-normalizer"
version = "3.4.9"
version = "3.5.1"
requires_python = ">=3.7"
summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
groups = ["default"]
files = [
{file = "charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917"},
{file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41"},
{file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1"},
{file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf"},
{file = "charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48"},
{file = "charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b"},
{file = "charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501"},
{file = "charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5"},
{file = "charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b"},
{file = "charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8"},
{file = "charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102"},
{file = "charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5"},
{file = "charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3"},
{file = "charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36"},
{file = "charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7"},
{file = "charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b"},
{file = "charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b"},
{file = "charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f"},
{file = "charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7"},
{file = "charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9"},
{file = "charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712"},
{file = "charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a"},
{file = "charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d"},
{file = "charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc"},
{file = "charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959"},
{file = "charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e"},
{file = "charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6"},
{file = "charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3"},
]
[[package]]
name = "click"
version = "8.4.2"
version = "8.5.0"
requires_python = ">=3.10"
summary = "Composable command line interface toolkit"
groups = ["default"]
dependencies = [
"colorama; platform_system == \"Windows\"",
]
files = [
{file = "click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76"},
{file = "click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6"},
{file = "click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360"},
{file = "click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34"},
]
[[package]]
@@ -106,8 +162,8 @@ name = "colorama"
version = "0.4.6"
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
summary = "Cross-platform colored terminal text."
groups = ["default", "dev"]
marker = "platform_system == \"Windows\" or sys_platform == \"win32\""
groups = ["dev"]
marker = "sys_platform == \"win32\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
@@ -186,43 +242,43 @@ files = [
[[package]]
name = "jh2"
version = "5.0.13"
version = "5.0.14"
requires_python = ">=3.7"
summary = "HTTP/2 State-Machine based protocol implementation"
groups = ["default"]
files = [
{file = "jh2-5.0.13-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:1dd137086b13d55a53c4b7b8b75869c824060a4d35615e4f49180ae58c7783f5"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75d0b2bffb2e260a0f3578a11a4999a713c94d1bd8de7cfe692c840e20ae83cf"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f681e197bf6552df3bb7a3abfd676e81316b69e36e7263e72383477174032ca2"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bde7e7ff0f17e87aca9ef23f66b80540a2de0ff8e2c26af55eb2e288a2a5f74f"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f8d364d53cc0854d910f2305726a8254acb527f13fa933dc213a591e21e7ffa"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b572daaafbd867bb5c5f324c918704ddd6d76af1cc7c2be483c3649b76ce7940"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0772ad66f346038fe05cb2928866ff1b21bcaa9ec3f14709fa1d419ed1dc287"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b1d8313b13acfdef19c5ff793fdf397bae3d8d75181f6eee62604f13e4ec20a8"},
{file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:99aedd20daacb32f377152c9103d8d0271f32d1932bd39a5ed2cd2eef97f7e07"},
{file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:53b1df29ffe966cde1d2f0085b6acfcd237504627d406a51717ef3849ec88831"},
{file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:0ee6a5dd24a1dc0772a89b3498ed20f60167fbc633404b1e02895f04ae66c47c"},
{file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:521f871864880cabe361060f070516a56a54abc3e96f07bee9ba8b061eada891"},
{file = "jh2-5.0.13-cp314-cp314t-win32.whl", hash = "sha256:5909e2273f8b6fcea93c95564aa0f366ff9261a544a7a5d640ea95341321cc37"},
{file = "jh2-5.0.13-cp314-cp314t-win_amd64.whl", hash = "sha256:2cc4923ac6f57753e0cb4aef054a04f5d5c744b274dabf863f695fa3c2fd75f9"},
{file = "jh2-5.0.13-cp314-cp314t-win_arm64.whl", hash = "sha256:d3cf203de31371289d4c1e6b4d30c9f6de95b2bd2b300fe5468ff946299d0e48"},
{file = "jh2-5.0.13-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:3f54095cde010ff4e52f23dda92b31d3d3160c64f5f23fc6e181f8c7938afd33"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f810dd02b4a9647eacbe4eab617111c0e3ffc68e923e3ff172948db601d2ba1e"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29c23f3937f257beb5cd5b17f8727404f1267ff25d26e54c0a35d5defd0c895e"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c697b250a935030a128b45b41f507a4d33a082d8b8e12311c7e73fbb3970f057"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47d3203ed6fec39df4922764817979fad3a13d22cf750adc9cf9dc2b3399eacf"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5146468a76bbe8f4035f23a78813c5c433c70c0ca44eb0ba5558627a9e644b8"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d3d338284fbfbe173243c3cc62e510c6fa0d6d023e67da2292bd7578c734e3"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:641f65f5414b8990251bd7609513a696831558f69d2ca77d65dc3f7087f31267"},
{file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:cf3dc6f7c14340241260bf63e5d6006b2ee7db45dafcb39c16fdf164ea0240a0"},
{file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:fb8e37ecf37382c02af83da521df5a77561441bc4cbb93b00627e015b7bbf349"},
{file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:296ee44dfad08ccc3857daf433dfa4a555d5b45f2df39503ccdd90ff10b32943"},
{file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1a75eed545420a31ea356e8046b8d86fcf694234dd1035191734a03720fb8a44"},
{file = "jh2-5.0.13-cp37-abi3-win32.whl", hash = "sha256:4812710810010c428db04e9290a13117f7b7c5a3a663b46a0586fe101cf25c9d"},
{file = "jh2-5.0.13-cp37-abi3-win_amd64.whl", hash = "sha256:5d61bc0a62b4eee11039bf29d4bdbb26efcff760dfb9d8e0739740028aa52b0f"},
{file = "jh2-5.0.13-cp37-abi3-win_arm64.whl", hash = "sha256:3f74dad9036e599eed51576492b00955237b86cf886f96844708a81cd0f5c710"},
{file = "jh2-5.0.13-py3-none-any.whl", hash = "sha256:2b311414989da48a155721fe47d0dc3e3b75360efd62d779aaff72d786be91cf"},
{file = "jh2-5.0.13.tar.gz", hash = "sha256:f8c78cffb3a35c4410513c3eb7989de36028c84277c04f07c97909dd94c23a75"},
{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"},
]
[[package]]
@@ -278,7 +334,7 @@ files = [
[[package]]
name = "niquests"
version = "3.21.0"
version = "3.21.1"
requires_python = ">=3.7"
summary = "Niquests is a simple, yet elegant, HTTP library. It is a drop-in replacement for Requests, which is under feature freeze."
groups = ["default"]
@@ -288,13 +344,13 @@ dependencies = [
"wassima<3,>=1.0.1; sys_platform != \"emscripten\"",
]
files = [
{file = "niquests-3.21.0-py3-none-any.whl", hash = "sha256:dbea441b9e9d5d755e02caa2b57b94cdb97b23d754ead21b80a4ac18ef66805f"},
{file = "niquests-3.21.0.tar.gz", hash = "sha256:5b7d10a05f4c7ed08cede0af74f492ae7a8a5a71291833d029e23365fc3ea80a"},
{file = "niquests-3.21.1-py3-none-any.whl", hash = "sha256:ac1ff8061cb480cdaad07f08b95cf21ee0a68ef21fbc2b6ddf5afff8e0d0072e"},
{file = "niquests-3.21.1.tar.gz", hash = "sha256:8ba6ee8712570ae41dfc59ac2ac7e509dcde660c6cc47078b43be423a2f99e64"},
]
[[package]]
name = "nodejs-wheel-binaries"
version = "24.16.0"
version = "24.19.0"
requires_python = ">=3.7"
summary = "unoffical Node.js package"
groups = ["dev"]
@@ -302,15 +358,15 @@ dependencies = [
"typing-extensions; python_version < \"3.8\"",
]
files = [
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:d9f8f677dcf30e37ac244f07869726abe043f01eb0f45722b1df31cc2af7093c"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:3d0370fe7120ce9697a4f60d40480d2bd8808d9f30131458d5afc0040d4e5a51"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:85dc92bbb79c851569c5925dcc2a4c915a034efab375f99e4e7e6bbe9cca8342"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:2f3036292811514ba847b3708492644764f88a833ac425c5f55007014308ddfd"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:db8a8a76ebd2b28ecbfc9ad464baa3707241b9e050a30e2efdf6f60c0f886502"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f1a3d8f7b4491cbbd023ba3fc4e901fcca2d9fb80d57f24ba3890de8b1dbac03"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-win_amd64.whl", hash = "sha256:bb136be9944f0662dcf1120f45193a6b75b13fac378971a95cc42c9f879a81aa"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-win_arm64.whl", hash = "sha256:8308940b5edd0a50dc5267ea36ba21c9f668e83fe0d9f293937174d3a7e31c36"},
{file = "nodejs_wheel_binaries-24.16.0.tar.gz", hash = "sha256:c973cb69dc5fd16e6f6dc6e579e2c3d5534e2a1f57619dddf5ba070efa7dde37"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:e12cbfd69089504e42fb14194ce734a9dcf3eb38c820ca63dd511d36fb964e9c"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:1c890adf4b7e6556ccc1ca66c866bb81884b6c9a581dee4e530dc7f78fb9d514"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4e029dadfae1295876063c96b236f673487e8c27379fe146c1e2250283520227"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4196a947bcc883f2003ab101762d729f3e99b5e86b75bd09151563403e2eceb8"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:352e048ab4dd35e7de5f338d1cc4fcbf77a0e93da30bf7336a8217ee246b31d7"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:28d078b2ced9e2069516e652dc4b1380e7a1a7f2d3934eccd1611586d283ba4c"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-win_amd64.whl", hash = "sha256:67e3abeb9c3830cae8c8487ae8a2af7cc27dfa75af06145cee5ca7d1857c81bd"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-win_arm64.whl", hash = "sha256:d9074c665ea68b04e183d82482c86dc907d3a9bd15eb6cf85542cb785266bb36"},
{file = "nodejs_wheel_binaries-24.19.0.tar.gz", hash = "sha256:db217eef8cab8551667863379b08db4d9067403f6cbbe87481eb40edceb8aa9b"},
]
[[package]]
@@ -326,13 +382,13 @@ files = [
[[package]]
name = "platformdirs"
version = "4.11.2"
version = "4.11.7"
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.2-py3-none-any.whl", hash = "sha256:7f89089b6ea71bda7962953edcf784b2e2d9d285b40ad88be2bb75c6e9d82ab4"},
{file = "platformdirs-4.11.2.tar.gz", hash = "sha256:3a2ae5fca3520a01ab1be8b45613537f52ddf5b5f6f53d88233892dfbf0cd82d"},
{file = "platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6"},
{file = "platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d"},
]
[[package]]
@@ -348,13 +404,13 @@ files = [
[[package]]
name = "pygments"
version = "2.20.0"
version = "2.21.0"
requires_python = ">=3.9"
summary = "Pygments is a syntax highlighting package written in Python."
groups = ["default", "dev"]
files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
{file = "pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9"},
{file = "pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"},
]
[[package]]
@@ -410,47 +466,47 @@ files = [
[[package]]
name = "qh3"
version = "1.9.4"
version = "2.0.3"
requires_python = ">=3.7"
summary = "A lightway and fast implementation of QUIC and HTTP/3"
summary = "A lightweight and fast implementation of QUIC and HTTP/3"
groups = ["default"]
marker = "(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 = "qh3-1.9.4-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:100368855b0e103774438aaf8b38c711fb51b79f18e3b9dc873ea573648bcc3d"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b9d45e544e0a556380bd2a92af180ee98d32bca6cf622f28350ebc000611bdf"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43f80d662078c26563a1aac32a4df22649473d996bf2422e073c8aa55b1c837c"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:551f60d487d849d6f2313dda94e54ebb16b1a29acb93676540ae790a56308c24"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3755d777ff35561bc719f571ebc4b91aaa8a945c210e50b2576bf2cc69cb511f"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04b64bb18e10d8f482bea1ef43e5da3448e2d5ba78bb0c735da5b9e02d7dfb0e"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b47597daa4ae9195f81a35822217a395d3277f01a393d9c2bb3796a6b458b22c"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9bb9012bc02c44da6af1b931b6082588519685fad8a1f866c341f749394f53f"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:9c7f3781ec022dd958b5eb0e5cb2e2a54e207a3d8837fb7290bc239912648fd7"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:633a24ab2db76d64881876c9d0f1b226a47d892bd7d78e61ea2c788ab59570af"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:f2ff36b84a4ab9b68806af1f6985fa3b47e29695effe766a3e70c21efac8157c"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:2100697d736dbdfd1d3f0266c9fcadea6230b66ed59e3e7f8c9a9d1a6eec0c65"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:9ce179efaf97f927f01fe158619f5624d8d874c7161b7c497f06897cdde210d5"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:476ee4fcdbd3454eb0eb4dee5678294f06c08ca8e8b83160e2d0ae3c4ae34603"},
{file = "qh3-1.9.4-cp314-cp314t-win32.whl", hash = "sha256:fe9df8136bf358d45a2757287523abaeb208fcbe2c1e34664562eb2d8f64dbdb"},
{file = "qh3-1.9.4-cp314-cp314t-win_amd64.whl", hash = "sha256:1fbdf53feb1732faf6c1cb34f2e1cbe4b543054db7b4d19d8752f1162e9370be"},
{file = "qh3-1.9.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e03ee1e2157a31c69415c3dbfbfe64e02469499ca46e968d13d8a7b289bf520a"},
{file = "qh3-1.9.4-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:26f7c8ed1a7ac0e25b654f030a4f472830c80890cc284dc40a31c17ecc21fbf0"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a57b3dfe5dd2da2084adea63dba0bf587a4096ebf9207339187c90139d1f460d"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7fa1a3debb5199da3c7f45b0ca6efa55174dfd6229370e774099073143ebe9e9"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6dc9ff40d3a06f27b47d93417666f03ba6fd6b9585d9711ebbddf84fa4b7c50f"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:010b132c33719af3df4c1d6f569e52a2a20046c55a31c5532e741019c8c53868"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:124045b0c04c05afc038a239d7717019b4c4b9d0cb967040b4331f4d588eff7d"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a94a1726bf46a451a0295d68fafde28794e83ab6fda7457cbd9309c38151de9"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b361e627b6a529ecb231c1d5f3f2abd6d1952b0d7cc02c7718d9657d55dae37"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:43b6cfb2240836e3e54bbe61ad1b8fa5716516ff5359131a2421c55676a24f26"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e6db3def610e9c2d2f3602d3e9c8b2156273225f6eedf74a4749d3bc21af8167"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:22d5879dc93d2c7ee14cc5933b12c7780d9c7705dff4553becea152fcb767d99"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:2c2b2005fe6c605d61cac05ec2b3c019cc546184fa43444f3d1e3d22ed020ab2"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:e991b0f20b172fea466d0ffd499a85bf2f26decdeeda537275c72b5f7b26ccfe"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c13775ed27a88b297f6a1aa05ffb286879453f99e1e53e0af2b22e7ed8f92ebf"},
{file = "qh3-1.9.4-cp37-abi3-win32.whl", hash = "sha256:952abe1be90c0114993886792a8a5ec2211f8391df41227f5b7a5a02ff71bc26"},
{file = "qh3-1.9.4-cp37-abi3-win_amd64.whl", hash = "sha256:5543cb40a48d401f3fa4ec89398e3a213634f195716a9d06d70417c05edbbb32"},
{file = "qh3-1.9.4-cp37-abi3-win_arm64.whl", hash = "sha256:d0bd73b3e73f4f80d2430d410b532222ecf57e9d4fe8600b7683c775cbff9479"},
{file = "qh3-1.9.4.tar.gz", hash = "sha256:bd2ea9baf19656c544a48a56a195f2ac257cd973b566f5f2998fa3b7446281a1"},
{file = "qh3-2.0.3-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:db98ea31b7486cac5dffc76c15b34c08208b219379aa9d274df21b1d47c7dbcb"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bb0a8f54275e0ecb722c0a6cebeff73771b31da0dbfaed8e4b72efcbe98e2ab"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e466a285b8f598ea06a296910a817571f132bb3ff3f5a26cdc905ffe6d9d743c"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a266a68f27a86d6d49978e1c6b8e9e5dd514e018a042dafca48cbeb50a29d0d3"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2646d05d20e7171d928d9b478097743d0d2a5b3893f8caddd7984b2825d90daa"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ccac4672539eb2897c3beaa73b626460040adf94c30edbd420db3988bd08e0f1"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0e57b96af05653ddcc4797fe761918d5d0a25ed90a9176260333f48e7b88f72"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73845b61987f44c5d55f9606a38b4078cac7e816dadeeaceb35a49ff741d4c8e"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:4f05f1b3ac250213db0da6895aeb944ca3cb63cd1a41296f1dddafd8d78a1722"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3be9e2cfc447807c4195c12d9590052ee45cc17ed92d99f44d05a49d657bf8d4"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:bb83310f908a9d00b781fe95f7acda51025d0cea3a985929e84b0d46333709a2"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:991ff127a8b0aef9ae07a4155f595158fd005d9cc522d204ec31a5a5f197517c"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:efbcd87e7c45f51690f6e526ed4956c57e428eb4c5684878987d43ffa14a93b0"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:86f2256aa78604d6733ff12a225d6780df18a9b171e72d45dd30ee62d5ab0568"},
{file = "qh3-2.0.3-cp314-cp314t-win32.whl", hash = "sha256:91569e8ee979c84a1972916cc9b3283760dda111fc8f67cce45ccdb4f14b67c5"},
{file = "qh3-2.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:eb0333f67bee13a3b5f08e91ece87fd0f5e5448e955efb11d7bd2bfacf21bfb5"},
{file = "qh3-2.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:8c9f6b22d073948b3042ec0b50dab5581865a30d41517ba6aad8dbc1a4b39a6b"},
{file = "qh3-2.0.3-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e96296b00eb23ff828d7789070b3d22c7139efbc6bc62fed366c31bad622d174"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a09e33277ded5f94f9b73b0c8c2f5b3e044f3c41ed5c830ab61e72962e22d09"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8bbbc57b1cff5acebf736965c072f35023f4d0e5975b3d8cda7b7e07250af1b"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06b8ee7434cd0ecab6b916abc63a46f4ad8b3ebbc284da405aeb650882826e91"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ec715af40af199c92eedbd620bbff8ddc514e19f2bbd660e65953752c364105f"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bcbdc6a574f83f957e6bf430cda8b98a512870164563b7ef1fdd553420f032a"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5c9ba43b3f68f9ade9553ff23d89455596b9c5ba8179a103f75c8771d355535"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51480d90b4ac7e81248fd020a726d4089cf8b881a4b6cbdc3c04c329831a3fc6"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:235161f4cbd791e18ea865d10fdd52d0ddbcbb807a1849c98360c4e570c06844"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7abb8ae6c8ae6b49827b26eaffeac0892893450d2022818e1db3ff34d26a835e"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:57f43869ef306877167277c48c1fa7c446fc73fc9355fa1ccae5094507b63d9e"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:192021606e283a59b47bf1e1cb719087c77869976aea76f26d589a1df8aa0dd5"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:f33ca1ac278a8d8ff115bcde76fc4555fa1dbfa147e83dad2ebcba6bc663e6d5"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:74814e3dd91011235905856701037ead68f7a8be641f469fa5e5cdabf7d03a28"},
{file = "qh3-2.0.3-cp37-abi3-win32.whl", hash = "sha256:20ceb5482ef6f352d9c7351fbde69f94f49d07f068ce9aa4e88ea76fca7eddda"},
{file = "qh3-2.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:94ba6ddb786742ff93904db09cb4b6fbcaf7de115161c05918024cd52b2c8a61"},
{file = "qh3-2.0.3-cp37-abi3-win_arm64.whl", hash = "sha256:0b13789c68983a994e8c12e3ff0fbc02f07c0aaaeec5670b57802f5d3d555e81"},
{file = "qh3-2.0.3.tar.gz", hash = "sha256:546ab2d3193e37e98dfefe5834484e07d0bf531708f21e1a4e227a51510b648f"},
]
[[package]]
@@ -497,7 +553,7 @@ files = [
[[package]]
name = "sqlalchemy"
version = "2.0.51"
version = "2.0.52"
requires_python = ">=3.7"
summary = "Database Abstraction Library"
groups = ["default"]
@@ -507,22 +563,16 @@ dependencies = [
"typing-extensions>=4.6.0",
]
files = [
{file = "sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491"},
{file = "sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d"},
{file = "sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54"},
{file = "sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e"},
{file = "sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d"},
{file = "sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8"},
{file = "sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2"},
{file = "sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5"},
{file = "sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9"},
{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"},
]
[[package]]
@@ -549,28 +599,28 @@ files = [
[[package]]
name = "urllib3-future"
version = "2.24.900"
version = "2.24.907"
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"]
dependencies = [
"h11<1.0.0,>=0.11.0",
"jh2<6.0.0,>=5.0.3",
"qh3<2.0.0,>=1.5.4; (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\"",
"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.900-py3-none-any.whl", hash = "sha256:a4fe06b59b0c3ce2fffa821c3de72eaf59b1e61619becc255dee18919ee25260"},
{file = "urllib3_future-2.24.900.tar.gz", hash = "sha256:05c2e9d09293ab29a154fed8f5b60644c7cdaffe4ae2587a07b1c7f00f23a7fb"},
{file = "urllib3_future-2.24.907-py3-none-any.whl", hash = "sha256:6f9007bd9c6d3ea13d8fe70e5d4b87a6d15638a849bd2227ab5cbdfbe5d1e842"},
{file = "urllib3_future-2.24.907.tar.gz", hash = "sha256:6565b3cc160a950821953079a6ccd4daa8fd60071a13c9a251576648929f3aa7"},
]
[[package]]
name = "wassima"
version = "2.1.3"
version = "2.1.4"
requires_python = ">=3.7"
summary = "Access your OS root certificates with utmost ease"
groups = ["default"]
marker = "sys_platform != \"emscripten\""
files = [
{file = "wassima-2.1.3-py3-none-any.whl", hash = "sha256:f8251c23720dd092117a554b3012756caee3feed6d0122c2458a2aca5d7d92c5"},
{file = "wassima-2.1.3.tar.gz", hash = "sha256:fcd6c38be0f909c393da35cb2a993101fcdcff673b8fa8d5da228f73b630d0d0"},
{file = "wassima-2.1.4-py3-none-any.whl", hash = "sha256:26022272d4618aada0e26df2e79dbc0e034be8be9d6d5396512690ffc6a94e7b"},
{file = "wassima-2.1.4.tar.gz", hash = "sha256:21bb77253bb8032c05393172c4d2df395f423b7e704538f3b2407f50c032034d"},
]
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "plyngent"
version = "0.4.0"
version = "0.6.0"
description = "LLM chat and agent toolkit"
authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" },
+1 -1
View File
@@ -22,7 +22,7 @@ DEFAULT_WIN_PYTHON = "3.14"
DEFAULT_WIN_UV_VERSION = "0.11.28"
# typings/ is required for Wine basedpyright: pywinpty's shipped types are incomplete
# (e.g. kill(sig) required); pyright default stubPath is ``typings/``.
VIEW_LINKS = ("src", "tests", "scripts", "typings", "README.md", "LICENSE", "CLAUDE.md", "doc")
VIEW_LINKS = ("src", "tests", "scripts", "typings", "README.md", "LICENSE", "AGENTS.md", "doc")
VIEW_COPIES = ("pyproject.toml", "pdm.lock")
+1
View File
@@ -2,6 +2,7 @@ from .chat import ChatAgent as ChatAgent
from .chat import get_lineno_read_files as get_lineno_read_files
from .chat import invalidate_lineno_read as invalidate_lineno_read
from .chat import lineno_read_lines as lineno_read_lines
from .chat import lineno_read_mtime as lineno_read_mtime
from .chat import mark_lineno_read as mark_lineno_read
from .chat import reset_lineno_tracker as reset_lineno_tracker
from .chat import was_lineno_read as was_lineno_read
+37 -10
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from contextvars import ContextVar, Token
from pathlib import Path
from typing import TYPE_CHECKING
from msgspec import UNSET
@@ -47,14 +48,18 @@ if TYPE_CHECKING:
# Per-conversation tracking of lines read with ``with_lineno=True``, keyed by
# resolved file path. Set to a fresh ``dict`` at the start of each user turn via
# :func:`reset_lineno_tracker`; cleared after the assistant finishes.
# Tool handlers read/write via :func:`get_lineno_read_files` /
# :func:`lineno_read_lines` / :func:`mark_lineno_read`.
_lineno_read_files: ContextVar[dict[str, set[int]] | None] = ContextVar("_lineno_read_files", default=None)
# resolved file path, with the file's mtime at read time (so stale line numbers
# after external/process mutations are detected). Set to a fresh ``dict`` at the
# start of each user turn via :func:`reset_lineno_tracker`; cleared after the
# assistant finishes. Tool handlers read/write via :func:`get_lineno_read_files` /
# :func:`lineno_read_lines` / :func:`lineno_read_mtime` / :func:`mark_lineno_read`.
_lineno_read_files: ContextVar[dict[str, tuple[set[int], int]] | None] = ContextVar(
"_lineno_read_files",
default=None,
)
def reset_lineno_tracker() -> Token[dict[str, set[int]] | None]:
def reset_lineno_tracker() -> Token[dict[str, tuple[set[int], int]] | None]:
"""Replace the tracker with a fresh empty mapping; return the previous token.
Call at the start of a new user turn. Restore with
@@ -80,17 +85,38 @@ def lineno_read_lines(path: str) -> set[int]:
val = _lineno_read_files.get()
if val is None:
return set()
return set(val.get(path, ()))
entry = val.get(path)
return set(entry[0]) if entry is not None else set()
def lineno_read_mtime(path: str) -> int | None:
"""Return the file mtime (ns) recorded when *path* was read, or None."""
val = _lineno_read_files.get()
if val is None:
return None
entry = val.get(path)
return entry[1] if entry is not None else None
def mark_lineno_read(path: str, lines: set[int]) -> None:
"""Record that *path* was read with line numbers this turn.
``lines`` are 1-based absolute file line numbers (``read_file`` slice).
The file's current mtime is recorded too, so edits can detect that the
file changed since the read (external/process mutations included).
"""
val = _lineno_read_files.get()
if val is not None:
val.setdefault(path, set()).update(lines)
if val is None:
return
try:
mtime_ns = Path(path).stat().st_mtime_ns
except OSError:
mtime_ns = 0
entry = val.get(path)
if entry is None:
val[path] = (set(lines), mtime_ns)
else:
val[path] = (entry[0] | lines, mtime_ns)
def invalidate_lineno_read(path: str) -> None:
@@ -569,7 +595,8 @@ class ChatAgent:
- Tools default **off**. ``tools=True`` clones this agent's registry with
a **fresh session** bag (unless *session_state* is passed) and the
given *instance_state* (CLI typically shares the host instance for
workspace identity).
workspace identity). A caller-built :class:`ToolRegistry` (e.g. a
``clone(read_only_only=True)`` subset) is used as-is.
"""
text = user_text.strip()
if not text:
+10 -1
View File
@@ -42,6 +42,7 @@ class ToolTag(Flag):
YOLO = auto() # soft-confirm: eligible for YOLO auto-approve
INSTANCE_STATE = auto() # needs instance-scoped state
SESSION_STATE = auto() # needs session-scoped state
READ_ONLY = auto() # never mutates host state; safe for read-only contexts
class ToolDefinition:
@@ -266,14 +267,22 @@ class ToolRegistry:
session_state: object | None = None,
yolo: bool | None = None,
auto_bind_state: bool | None = None,
read_only_only: bool = False,
) -> ToolRegistry:
"""New registry with the same tools/hooks and rebound host state.
*instance_state* / *session_state* are applied as given (use the parent
registry's handles only when the caller passes them through).
With *read_only_only*, only :attr:`ToolTag.READ_ONLY` tools are kept
(e.g. for safe side turns / planning); hooks and policy flags are
preserved.
"""
definitions = self.definitions()
if read_only_only:
definitions = [d for d in definitions if d.tags & ToolTag.READ_ONLY]
return ToolRegistry(
self.definitions(),
definitions,
danger=self._danger,
on_confirm=self._on_confirm,
yolo=self._yolo if yolo is None else yolo,
+54 -5
View File
@@ -26,7 +26,7 @@ from plyngent.cli.state import ReplState
from plyngent.config.models import DatabaseConfig
from plyngent.memory import MemoryStore
from plyngent.prompting import NonInteractiveBackend, configure_prompting
from plyngent.runtime import ProviderNotSupportedError, create_client
from plyngent.runtime import McpManager, ProviderNotSupportedError, create_client
if TYPE_CHECKING:
from collections.abc import Mapping
@@ -124,9 +124,14 @@ def _read_prompt_text(prompt: str | None, *, stdin_isatty: bool) -> str | None:
return text or None
def _setup_hooks(*, interactive: bool) -> None:
"""Install interactive limit hooks (workspace policy is set on ReplState)."""
if interactive:
def _setup_hooks(*, interactive: bool, auto_continue: bool) -> None:
"""Install limit hooks (workspace policy is set on ReplState).
``auto_continue`` keeps the PTY/limit hook installed for one-shot runs so
``--auto-continue`` / ``[agent] auto_continue_limits`` can raise limits
without a prompt; otherwise non-interactive runs keep today's deny.
"""
if interactive or auto_continue:
install_cli_limit_hooks()
else:
from plyngent.tools.process.pty_session import PtyManager
@@ -134,6 +139,24 @@ def _setup_hooks(*, interactive: bool) -> None:
PtyManager.set_limit_continue_hook(None)
async def _start_mcp_manager(store: ConfigStore) -> McpManager | None:
"""Start configured MCP servers before the REPL state is built.
Returns ``None`` when nothing is enabled. Failures degrade to a warning
per server (the rest of the session, including LLM traffic, continues).
"""
manager = McpManager(store.mcp_config)
if not manager.enabled_servers():
return None
await manager.ensure_started()
for name, status, count in manager.statuses():
if status.startswith("error"):
click.secho(f"warning: mcp server {name!r} failed: {status}", fg="yellow", err=True)
else:
click.secho(f"mcp: {name} ({status}, tools={count})", fg="bright_black", err=True)
return manager
async def _bind_session(
state: ReplState,
*,
@@ -203,6 +226,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
prompt_text: str | None,
stream: bool,
yes: bool,
auto_continue: bool,
quiet: bool,
) -> int:
oneshot = prompt_text is not None
@@ -218,7 +242,8 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
if store.recoverable_providers:
_warn_recoverable_providers(store.recoverable_providers)
_setup_hooks(interactive=interactive)
auto_effective = auto_continue or store.agent_config.auto_continue_limits
_setup_hooks(interactive=interactive, auto_continue=auto_effective)
# --yes forces sticky YOLO; else derive from config.confirm_destructive.
from plyngent.cli.state import YoloMode
@@ -285,6 +310,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
except ProviderNotSupportedError as exc:
raise click.ClickException(str(exc)) from exc
mcp_manager = await _start_mcp_manager(store)
state = ReplState(
config=store,
memory=memory,
@@ -296,7 +322,9 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
max_rounds=max_rounds,
stream_enabled=stream,
interactive_limits=interactive,
auto_continue_limits=auto_effective,
yolo=yolo,
mcp_manager=mcp_manager,
)
# Path denylist and policy confirm live on instance.workspace (no process bag).
state.instance_state.workspace.path_denylist = tuple(store.agent_config.path_denylist or ())
@@ -313,6 +341,10 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
prompt_policy_fetch_confirm,
instance=state.instance_state,
)
from plyngent.tools.access import set_directory_access_confirm_hook
# Installed for one-shot too: the hook itself honours --yes / non-TTY.
set_directory_access_confirm_hook(state.directory_access_confirm_hook, instance=state.instance_state)
# Seed cache if we already fetched; else warm in background for Tab.
if remote_ids is not None:
state.seed_remote_models(remote_ids)
@@ -353,6 +385,15 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
set_fetch_policy_confirm_hook(None, instance=state_obj.instance_state)
clear_private_grants(instance=state_obj.instance_state)
clear_ssrf_assume_public_cidrs()
from plyngent.tools.access import clear_process_access, set_directory_access_confirm_hook
set_directory_access_confirm_hook(None, instance=state_obj.instance_state)
clear_process_access(instance=state_obj.instance_state)
from plyngent.cli.limits import reset_auto_continue
reset_auto_continue()
if state_obj.mcp_manager is not None:
await state_obj.mcp_manager.aclose()
await state_obj.instance_state.shutdown()
else:
# No ReplState: only PTY class cleanup (temps require instance allowlist).
@@ -447,6 +488,12 @@ def main(ctx: click.Context, log_level: str) -> None:
default=False,
help="Enable YOLO: skip destructive-tool confirms (sticky for this process).",
)
@click.option(
"--auto-continue",
is_flag=True,
default=False,
help="Auto-raise tool/PTY limits without prompting (like answering yyy).",
)
@click.option(
"--quiet",
is_flag=True,
@@ -465,6 +512,7 @@ def chat_cmd(
prompt: str | None,
stream: bool, # noqa: FBT001
yes: bool, # noqa: FBT001
auto_continue: bool, # noqa: FBT001
quiet: bool, # noqa: FBT001
) -> None:
"""Interactive chat REPL, or one-shot with ``-p`` / stdin.
@@ -505,6 +553,7 @@ def chat_cmd(
prompt_text=prompt_text,
stream=stream,
yes=yes,
auto_continue=auto_continue,
quiet=quiet,
)
)
+329 -23
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
import contextlib
import json
import os
import re
import shlex
import sys
from contextvars import ContextVar
from typing import TYPE_CHECKING, Literal, cast
@@ -22,7 +24,7 @@ from plyngent.agent import (
from plyngent.lmproto.openai_compatible.model import AssistantFunctionToolCall
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Callable
from plyngent.agent import AgentEvent
@@ -31,7 +33,29 @@ _TOOL_ARGS_PREVIEW = 80
# Tool calls that render as a single pretty summary line instead of
# ``[tool]`` / ``[tool result]``.
_PRETTY_TOOLS = frozenset({"read_file", "todo_push", "todo_update"})
_PRETTY_TOOLS = frozenset(
{
"read_file",
"todo_push",
"todo_update",
"tree",
"listdir",
"glob_paths",
"grep_files",
"run_argv",
"run_argv_batch",
"fetch",
"edit_replace",
"edit_lineno",
"write_file",
"copy_path",
"move_path",
"delete_path",
"vcs_status",
"vcs_log",
"wait",
}
)
# Process/session display flags (set from ReplState / slash).
_verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False)
@@ -74,47 +98,330 @@ def _preview(text: str, limit: int) -> str:
return text[:limit] + "…"
def _json_str_arg(args_json: str, key: str) -> str | None:
"""Extract a string argument from a tool-call arguments JSON blob."""
def _preview_result(content: str, limit: int) -> str:
"""Non-verbose tool-result preview: first line + a ``(…N more lines)`` tail."""
lines = content.splitlines()
if not lines:
return "(no output)"
first = _preview(lines[0], limit)
more = len(lines) - 1
if more == 0:
return first
unit = "line" if more == 1 else "lines"
return f"{first} (…{more} more {unit})"
def _json_arg(args_json: str, key: str) -> object | None:
"""Extract a single argument value (any type) from a tool-call args JSON blob."""
try:
raw = json.loads(args_json)
except ValueError:
return None
if isinstance(raw, dict):
value = cast("dict[str, object]", raw).get(key)
if isinstance(value, str):
return value
return cast("dict[str, object]", raw).get(key)
return None
def _json_str_arg(args_json: str, key: str) -> str | None:
value = _json_arg(args_json, key)
return value if isinstance(value, str) else None
def _json_list_arg(args_json: str, key: str) -> list[str] | None:
value = _json_arg(args_json, key)
if isinstance(value, list):
raw_items = cast("list[object]", value)
items = [item for item in raw_items if isinstance(item, str)]
if len(items) == len(raw_items):
return items
return None
def _pretty_line(prefix: str, *segments: tuple[str, str | None]) -> str:
"""Style a pretty tool summary: yellow ``* Verb`` prefix + colored detail segments.
``segments`` are ``(text, fg)`` pairs; ``fg=None`` leaves a segment uncolored
and ``"dim"`` renders it dim. click.style emits ANSI codes only on a TTY,
so non-TTY output stays plain.
"""
parts = [click.style(prefix, fg="yellow")]
for text, fg in segments:
if fg == "dim":
parts.append(click.style(text, dim=True))
else:
parts.append(click.style(text, fg=fg))
return "".join(parts)
def _read_file_pretty(args_json: str, result: str) -> str:
"""One-line summary for a ``read_file`` call: ``* Read 'path' L1-4 (done)``."""
path = _json_str_arg(args_json, "path") or "?"
if result.startswith("error: file not found"):
return f"* Read '{path}' (file not found)"
return _pretty_line(f"* Read '{path}' ", ("(file not found)", "red"))
if result.startswith("error: not a file"):
return f"* Read '{path}' (not a file)"
return _pretty_line(f"* Read '{path}' ", ("(not a file)", "red"))
if result.startswith("error:"):
return f"* Read '{path}' (error)"
return _pretty_line(f"* Read '{path}' ", ("(error)", "red"))
first = result.splitlines()[0] if result.splitlines() else ""
if first.startswith("L") and "-" in first:
return f"* Read '{path}' {first} (done)"
return f"* Read '{path}' (done)"
return _pretty_line(f"* Read '{path}' ", (f"{first} ", "dim"), ("(done)", "green"))
return _pretty_line(f"* Read '{path}' ", ("(done)", "green"))
def _todo_pretty(name: str, result: str) -> str:
"""Summary for todo push/update: header + the rendered todo stack."""
label = "Todo Push" if name == "todo_push" else "Todo Update"
return f"* {label}:\n{result}"
return _pretty_line(f"* {label}:\n{result}")
def _tree_line_stats(fmt: str, line: str) -> tuple[str, int] | None:
"""Classify one ``tree`` result line as ``(kind, depth)`` or None (not an entry).
``kind`` is ``"dir"`` or ``"file"``; depth is 0 for ``flat`` (no hierarchy).
"""
stripped = line.strip()
if not stripped:
return None
kind: str | None = None
depth = 0
if fmt == "flat":
if not stripped.startswith("…"):
kind = "dir" if stripped.endswith("/") else "file"
elif fmt == "markdown":
if stripped.startswith("- ") and not stripped.startswith(("- …", "- error")):
kind = "dir" if stripped[2:].endswith("/") else "file"
depth = (len(line) - len(line.lstrip(" "))) // 2 + 1
elif "… (" not in stripped:
branch = "├── " if "├── " in line else ("└── " if "└── " in line else None)
if branch is not None:
name = line[line.index(branch) + len(branch) :].rstrip()
kind = "dir" if name.endswith("/") else "file"
depth = line.index(branch) // 4 + 1
if kind is None:
return None
return kind, depth
def _tree_pretty(args_json: str, result: str) -> str:
"""One-line summary for a ``tree`` call: ``* Tree 'src' (3 dirs, 24 files, depth 3)``.
Flat trees have no depth, so the depth part is omitted for ``format=flat``.
"""
path = _json_str_arg(args_json, "path") or "."
fmt = _json_str_arg(args_json, "format") or "markdown"
if result.startswith("error:"):
return f"* Tree '{path}' (error)"
dirs = 0
files = 0
depth = 0
for line in result.splitlines():
stats = _tree_line_stats(fmt, line)
if stats is None:
continue
kind, line_depth = stats
if kind == "dir":
dirs += 1
else:
files += 1
depth = max(depth, line_depth)
if depth:
return _pretty_line(f"* Tree '{path}' ", (f"({dirs} dirs, {files} files, depth {depth})", None))
return _pretty_line(f"* Tree '{path}' ", (f"({dirs} dirs, {files} files)", None))
def _listdir_pretty(args_json: str, result: str) -> str:
"""One-line summary for a ``listdir`` call: ``* List 'path' (3 dirs, 5 files)``."""
path = _json_str_arg(args_json, "path") or "."
if result.startswith("error:"):
return _pretty_line(f"* List '{path}' ", (f"({result})", "red"))
if result == "(empty)":
return _pretty_line(f"* List '{path}' ", ("(empty)", "dim"))
dirs = 0
files = 0
for line in result.splitlines():
if line.startswith("dir\t"):
dirs += 1
elif line.startswith("file\t"):
files += 1
return _pretty_line(f"* List '{path}' ", (f"({dirs} dirs, {files} files)", None))
def _glob_pretty(args_json: str, result: str) -> str:
"""One-line summary for a ``glob_paths`` call: ``* Glob '**/*.py' in '.' (12 paths)``."""
pattern = _json_str_arg(args_json, "pattern") or "?"
path = _json_str_arg(args_json, "path") or "."
if result.startswith("error:"):
return _pretty_line(f"* Glob '{pattern}' in '{path}' ", (f"({result})", "red"))
if result == "(no matches)":
return _pretty_line(f"* Glob '{pattern}' in '{path}' ", ("(no matches)", "dim"))
count = sum(1 for line in result.splitlines() if line and not line.startswith("...[truncated"))
unit = "path" if count == 1 else "paths"
return _pretty_line(f"* Glob '{pattern}' in '{path}' ", (f"({count} {unit})", None))
def _grep_pretty(args_json: str, result: str) -> str:
"""One-line summary for a ``grep_files`` call: ``* Grep 'pat' in '.' (12 matches in 3 files)``."""
pattern = _json_str_arg(args_json, "pattern") or "?"
path = _json_str_arg(args_json, "path") or "."
if result.startswith("error:"):
return _pretty_line(f"* Grep '{pattern}' in '{path}' ", (f"({result})", "red"))
if result == "(no matches)":
return _pretty_line(f"* Grep '{pattern}' in '{path}' ", ("(no matches)", "dim"))
matches = [line for line in result.splitlines() if line and not line.startswith("...[truncated")]
files = len({line.split(":", 1)[0] for line in matches})
match_unit = "match" if len(matches) == 1 else "matches"
file_unit = "file" if files == 1 else "files"
return _pretty_line(
f"* Grep '{pattern}' in '{path}' ",
(f"({len(matches)} {match_unit} in {files} {file_unit})", None),
)
def _run_argv_pretty(args_json: str, result: str) -> str:
"""One-line summary for a ``run_argv`` call: ``* Run $ git status --short (exit code 0)``."""
argv = _json_list_arg(args_json, "argv")
cmd = shlex.join(argv) if argv else "?"
if result.startswith("error:"):
return _pretty_line(f"* Run $ {cmd} ", (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
if fields.get("timed_out") == "true":
return _pretty_line(f"* Run $ {cmd} ", ("(timed out)", "red"))
code = fields.get("exit_code", "")
fg = "green" if code == "0" else "red"
return _pretty_line(f"* Run $ {cmd} ", (f"(exit code {code or 'killed'})", fg))
def _run_argv_batch_pretty(_args_json: str, result: str) -> str:
"""One-line summary for a ``run_argv_batch`` call: ``* Run batch (done)``."""
if result.startswith("error:"):
return _pretty_line("* Run batch ", (f"({result})", "red"))
lines = result.splitlines()
head = lines[0] if lines else ""
stopped_early = False
for part in head.split():
if part == "stopped_early=true":
stopped_early = True
if stopped_early:
return _pretty_line("* Run batch ", ("(stopped early)", "yellow"))
return _pretty_line("* Run batch ", ("(done)", "green"))
def _http_status_fg(status: str) -> str | None:
"""Foreground color by HTTP status class (2xx green, 3xx yellow, 4xx/5xx red)."""
if status and status[0] in {"2", "3", "4", "5"}:
return {"2": "green", "3": "yellow", "4": "red", "5": "red"}[status[0]]
return None
def _fetch_pretty(args_json: str, result: str) -> str:
"""One-line summary for a ``fetch`` call: ``* Fetch GET https://… (200)``."""
method = _json_str_arg(args_json, "method") or "GET"
url = _json_str_arg(args_json, "url") or "?"
if result.startswith("error:"):
return _pretty_line(f"* Fetch {method} {url} ", (f"({result})", "red"))
status = ""
for line in result.splitlines():
if line.startswith("--- "):
break
if line.startswith("status="):
status = line.partition("=")[2]
break
return _pretty_line(f"* Fetch {method} {url} ", (f"({status or 'done'})", _http_status_fg(status)))
_MUTATOR_VERBS: dict[str, str] = {
"edit_replace": "Edit",
"edit_lineno": "Edit",
"write_file": "Write",
"copy_path": "Copy",
"move_path": "Move",
"delete_path": "Delete",
}
def _mutator_pretty(name: str, args_json: str, result: str) -> str:
"""One-line summary for a file mutation: ``* Wrote 'path' (120 chars)`` / ``* Edit 'path' (done)``."""
verb = _MUTATOR_VERBS[name]
if name in {"copy_path", "move_path"}:
src = _json_str_arg(args_json, "src") or "?"
dst = _json_str_arg(args_json, "dst") or "?"
target = f"'{src}' → '{dst}'"
else:
path = _json_str_arg(args_json, "path") or "?"
target = f"'{path}'"
if result.startswith("error:"):
return _pretty_line(f"* {verb} {target} ", (f"({result})", "red"))
if name == "write_file":
# write_file keeps its brief detail: ``wrote 120 characters to path``.
match = re.match(r"wrote (\d+) characters to (.+)$", result)
if match:
chars, path = match.groups()
return _pretty_line(f"* Write '{path}' ", (f"({chars} chars)", None))
return _pretty_line(f"* {verb} {target} ", ("(done)", "green"))
def _vcs_status_pretty(_args_json: str, result: str) -> str:
"""One-line summary for ``vcs_status``: ``* VCS Status (done)``."""
if result.startswith("error:"):
return _pretty_line("* VCS Status ", (f"({result})", "red"))
return _pretty_line("* VCS Status ", ("(done)", "green"))
def _vcs_log_pretty(_args_json: str, result: str) -> str:
"""One-line summary for ``vcs_log``: ``* VCS Log (5 commits)``."""
if result.startswith("error:"):
return _pretty_line("* VCS Log ", (f"({result})", "red"))
if result == "(no commits)":
return _pretty_line("* VCS Log ", ("(no commits)", "dim"))
commits = [line for line in result.splitlines() if line.strip()]
unit = "commit" if len(commits) == 1 else "commits"
return _pretty_line("* VCS Log ", (f"({len(commits)} {unit})", None))
def _wait_pretty(_args_json: str, result: str) -> str:
"""One-line summary for a ``wait`` call: ``* Wait (5s)`` / ``* Wait (disturbed)``."""
if result.startswith("error:"):
return _pretty_line("* Wait ", (f"({result})", "red"))
if result.startswith("waited "):
return _pretty_line("* Wait ", (f"({result.removeprefix('waited ')})", "green"))
return _pretty_line("* Wait ", ("(disturbed)", "yellow"))
_PRETTY_BUILDERS: dict[str, Callable[[str, str], str]] = {
"read_file": _read_file_pretty,
"tree": _tree_pretty,
"todo_push": lambda _args, result: _todo_pretty("todo_push", result),
"todo_update": lambda _args, result: _todo_pretty("todo_update", result),
"listdir": _listdir_pretty,
"glob_paths": _glob_pretty,
"grep_files": _grep_pretty,
"run_argv": _run_argv_pretty,
"run_argv_batch": _run_argv_batch_pretty,
"fetch": _fetch_pretty,
"edit_replace": lambda args, result: _mutator_pretty("edit_replace", args, result),
"edit_lineno": lambda args, result: _mutator_pretty("edit_lineno", args, result),
"write_file": lambda args, result: _mutator_pretty("write_file", args, result),
"copy_path": lambda args, result: _mutator_pretty("copy_path", args, result),
"move_path": lambda args, result: _mutator_pretty("move_path", args, result),
"delete_path": lambda args, result: _mutator_pretty("delete_path", args, result),
"vcs_status": _vcs_status_pretty,
"vcs_log": _vcs_log_pretty,
"wait": _wait_pretty,
}
def _pretty_tool_result(name: str, args_json: str, result: str) -> str | None:
"""Pretty summary for a known tool call + result; None keeps the old style."""
if name == "read_file":
return _read_file_pretty(args_json, result)
if name in {"todo_push", "todo_update"}:
return _todo_pretty(name, result)
return None
builder = _PRETTY_BUILDERS.get(name)
if builder is None:
return None
return builder(args_json, result)
def _echo_stream(text: str) -> None:
@@ -258,14 +565,13 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
content = event.message.content
name, args, pretty_tool = pending_tools.pop(0) if pending_tools else ("", "", False)
pretty_line = _pretty_tool_result(name, args, content) if pretty_tool else None
if pretty_line is not None:
click.secho(f"\n{pretty_line}", fg="yellow")
if pretty_line is not None and not show_full:
click.echo(f"\n{pretty_line}")
elif show_full:
click.secho(f"[tool ok]\n{content}", fg="magenta")
else:
preview = _preview(content, _TOOL_RESULT_PREVIEW)
one_line = preview.replace("\n", " ")
click.secho(f"[tool ok] {one_line}", fg="magenta")
preview = _preview_result(content, _TOOL_RESULT_PREVIEW)
click.secho(f"[tool ok] {preview}", fg="magenta")
elif isinstance(event, ErrorEvent):
flush_assistant()
suffix = ""
+124 -8
View File
@@ -4,6 +4,7 @@ import contextlib
import selectors
import shutil
import sys
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
from plyngent.cli.interrupt import pause_task_cancel_for_prompt
@@ -18,16 +19,56 @@ from plyngent.prompting import (
confirm,
get_prompt_backend,
)
from plyngent.tools.access import AccessDecision
from plyngent.tools.process.pty_session import PtyManager
from plyngent.tools.workspace import AccessMode, parse_access_mode
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from pathlib import Path
type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"]
_BOX_MIN_WIDTH = 40
_BOX_MAX_WIDTH = 100
_BOX_PAD = 2 # spaces inside left/right borders
# Limit prompts accept ``yyy`` to stop asking for the rest of the turn.
_CONTINUE_PROMPT = "Raise limit and continue? [Y/n/yyy] (yyy = stop asking this turn): "
_CONTINUE_YES_TOKENS = frozenset({"", "y", "yes"})
_CONTINUE_AUTO_TOKEN = "yyy"
@dataclass
class _AutoContinue:
"""Auto-continue layers: process default (flag/config) + ``yyy`` turn skip."""
default: bool = False
turn: bool = False
_auto_continue = _AutoContinue()
def auto_continue_enabled() -> bool:
"""Whether limit prompts are auto-approved (process default or ``yyy``)."""
return _auto_continue.default or _auto_continue.turn
def set_auto_continue_default(*, enabled: bool) -> None:
"""Set the process-wide default (``--auto-continue`` / ``[agent]`` config)."""
_auto_continue.default = bool(enabled)
def reset_auto_continue_turn() -> None:
"""Clear the ``yyy`` skip at a turn boundary."""
_auto_continue.turn = False
def reset_auto_continue() -> None:
"""Clear both layers (chat exit / tests)."""
_auto_continue.default = False
_auto_continue.turn = False
def _terminal_width() -> int:
try:
@@ -104,10 +145,33 @@ def _echo_continue_limit(reason: str) -> None:
backend.echo()
def _echo_auto_continue(reason: str) -> None:
"""Echo the limit reason plus the ``yyy`` notice (no prompt)."""
backend = get_prompt_backend()
backend.secho(f"[limit] {reason}", fg="yellow")
backend.secho("[limit] auto-continuing (yyy) — no more limit prompts this turn", fg="bright_black")
backend.echo()
def _decide_continue_limit(reason: str) -> bool:
"""Shared limit decision for the sync/async prompts (may read a line)."""
if auto_continue_enabled():
_echo_auto_continue(reason)
return True
backend = get_prompt_backend()
if not backend.is_interactive():
return False
_echo_continue_limit(reason)
token = backend.read_line(_CONTINUE_PROMPT, default="y").strip().lower()
if token == _CONTINUE_AUTO_TOKEN:
_auto_continue.turn = True
return True
return token in _CONTINUE_YES_TOKENS
def _prompt_continue_limit_sync(reason: str) -> bool:
try:
_echo_continue_limit(reason)
return confirm("Raise limit and continue?", default=True)
return _decide_continue_limit(reason)
except NonInteractiveError:
return False
@@ -119,15 +183,11 @@ def prompt_continue_limit(reason: str) -> bool:
async def prompt_continue_limit_async(reason: str) -> bool:
"""Async variant: confirm off the event loop so the turn is not cancelled."""
"""Async variant: prompt off the event loop so the turn is not cancelled."""
try:
from plyngent.prompting import run_prompt_async
def _run() -> bool:
_echo_continue_limit(reason)
return confirm("Raise limit and continue?", default=True)
return await run_prompt_async(_run)
return await run_prompt_async(lambda: _decide_continue_limit(reason))
except NonInteractiveError:
return False
@@ -287,6 +347,62 @@ def prompt_policy_fetch_confirm(
return False
def prompt_directory_access_confirm(
path: Path,
mode: AccessMode,
reason: str,
timeout_seconds: float,
) -> AccessDecision | str | None:
"""Ask whether to grant access to *path* at *mode* (timed; default deny).
``y`` approves the requested level; ``read`` / ``write`` / ``exec`` approve
that level instead. The host's hook decides YOLO/``--yes`` before calling
this. Timeout, cancel, or non-interactive → ``None`` (deny).
"""
lines = [
f"request: {mode.name.lower()} access to {path}",
f"kind: {'directory' if path.is_dir() else 'file'}",
]
if reason:
lines.append(f"why: {reason}")
lines.append(f"(timeout {timeout_seconds:g}s defaults to DENY)")
try:
with pause_task_cancel_for_prompt():
backend = get_prompt_backend()
if not backend.is_interactive():
return None
backend.echo()
backend.secho(
format_tool_confirm_box("request_directory_access", "\n".join(lines)),
fg="yellow",
)
backend.echo()
prompt = (
f"[access] allow {path} ({mode.name.lower()})? [y/N|read|write|exec] (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"[access] timed out after {timeout_seconds:g}s — denied",
fg="red",
err=True,
)
return None
token = raw.strip().lower()
if token in {"y", "yes"}:
return AccessDecision(mode)
level = parse_access_mode(token)
if level is not None:
return AccessDecision(level)
return None
except NonInteractiveError, KeyboardInterrupt, EOFError:
return None
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool | str:
"""Async confirm: True allow, False deny, str = deny with user comment."""
del args
+4 -1
View File
@@ -36,14 +36,17 @@ def _echo_interrupted() -> None:
async def run_repl(state: ReplState) -> None:
"""Interactive chat loop with readline editing, history, and Tab completion."""
setup_readline(state)
from plyngent.cli.limits import auto_continue_enabled
yolo = state.effective_yolo()
yolo_part = f" yolo={yolo}" if yolo != "off" else ""
auto_part = " limit_auto=on" if auto_continue_enabled() else ""
click.echo(
f"plyngent chat provider={state.provider_name} model={state.model} "
f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} "
f"rounds={state.max_rounds} messages={len(state.agent.messages)} "
f"stream={'on' if state.agent.stream else 'off'} "
f"verbose={'on' if state.verbose else 'off'}{yolo_part}"
f"verbose={'on' if state.verbose else 'off'}{yolo_part}{auto_part}"
)
click.echo('Type /help for commands. Multiline: """ … """. Empty line is ignored.')
+17
View File
@@ -10,6 +10,7 @@ import click
from plyngent.cli.display import render_events
from plyngent.cli.interrupt import allow_task_cancel, set_sigint_reinstall
from plyngent.cli.limits import reset_auto_continue_turn
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable, Coroutine
@@ -184,7 +185,23 @@ async def run_turn_with_retries(
further attempts use ``agent.retry`` so the user message is not duplicated.
Ctrl+C cancels the in-flight task; user message stays in DB for ``/retry``.
The ``yyy`` limit auto-continue is turn-scoped: cleared before and after the
turn, so a retry of the same turn keeps it but the next user turn prompts.
"""
reset_auto_continue_turn()
try:
return await _run_turn_with_retries(agent, starter=starter, delays=delays)
finally:
reset_auto_continue_turn()
async def _run_turn_with_retries(
agent: ChatAgent,
*,
starter: Callable[[], AsyncIterator[AgentEvent]],
delays: tuple[float, ...],
) -> bool:
max_retries = len(delays)
attempt = 0
current: Callable[[], AsyncIterator[AgentEvent]] = starter
+183 -25
View File
@@ -24,6 +24,7 @@ from plyngent.runtime import ProviderNotSupportedError
if TYPE_CHECKING:
from collections.abc import Awaitable, Sequence
from plyngent.agent import ToolRegistry
from plyngent.cli.state import ReplState, YoloMode
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
@@ -83,7 +84,8 @@ HELP_FOOTER = (
"works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n"
"\n"
"Side questions: /btw … answers without changing the main session\n"
"(optional --tools / --fresh). Tab completes slash commands and arguments.\n"
"(--tools=read by default: read-only tools; --tools no/full opt-in; --fresh).\n"
"Tab completes slash commands and arguments.\n"
"Use --session ID or /resume to continue a prior chat after restart.\n"
"\n"
'Multiline: start a message with """ then end a later line with """.\n'
@@ -519,6 +521,7 @@ def config_cmd(state: ReplState) -> None:
def status_cmd(state: ReplState) -> None:
"""Show session/provider/tools/rounds status."""
from plyngent.agent.budget import estimate_messages_chars
from plyngent.tools.access import access_grant_rows
pending = state.agent.pending_retry_text
pending_disp = "yes" if pending else "no"
@@ -553,6 +556,7 @@ def status_cmd(state: ReplState) -> None:
f"usage_last_turn={last_u.format_line(billed=True)} "
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"workspace={state.workspace}"
)
@@ -928,11 +932,12 @@ def _format_tool_tags(tags: object) -> str:
@slash.command("btw")
@click.argument("question", nargs=-1, required=False)
@click.option(
"--tools/--no-tools",
"with_tools",
default=False,
"--tools",
"tools_mode",
type=click.Choice(["no", "read", "full"]),
default="read",
show_default=True,
help="Allow tools on the side turn (shared workspace; forked session grants/todo).",
help=("Tool access on the side turn: read = read-only tools only (default), no = no tools, full = all tools."),
)
@click.option(
"--fresh",
@@ -945,54 +950,85 @@ def btw_cmd(
state: ReplState,
question: tuple[str, ...],
*,
with_tools: bool,
tools_mode: str,
fresh: bool,
) -> None:
"""Ask a side question without changing the main session transcript or DB.
Uses the current model and (unless ``--fresh``) a copy of main history.
Tools are **off** by default; ``--tools`` clones the main tool registry with
a fresh session bag and the shared instance workspace.
Tools default to **read-only** (``--tools=read``): file/vcs/ask/fetch-GET
reads with no state mutation. ``--tools=no`` turns tools off and
``--tools=full`` clones the whole main registry with a fresh session bag
and the shared instance workspace.
"""
text = " ".join(question).strip()
if not text:
click.echo("usage: /btw [--tools] [--fresh] <question>")
click.echo("usage: /btw [--tools read|no|full] [--fresh] <question>")
return
if state.agent.pending_retry_text is not None:
click.echo("error: finish or /retry the main turn before /btw")
return
if with_tools and (not state.tools_enabled or state.agent.tools is None):
click.echo("error: --tools requires tools on (/tools on)")
return
tools_on = state.tools_enabled and state.agent.tools is not None
explicit = click.get_current_context().get_parameter_source("tools_mode") is not click.core.ParameterSource.DEFAULT
if tools_mode != "no" and not tools_on:
# Explicitly requesting tools while they are off is an error; the
# default (read) silently falls back to no tools so bare /btw still
# works under /tools off.
if tools_mode == "full" or explicit:
click.echo("error: --tools requires tools on (/tools on)")
return
tools_mode = "no"
from plyngent.cli.display import render_events
from plyngent.cli.retry import run_cancellable
from plyngent.tools.context import SessionState
from plyngent.tools.context import SessionState, read_only_context
click.secho("btw: ", fg="magenta", nl=False)
click.echo(text)
tools_arg: bool = with_tools
aside_session = SessionState() if with_tools else None
aside_instance = state.instance_state if with_tools else None
if tools_mode == "no":
tools_arg: ToolRegistry | bool = False
aside_session = None
aside_instance = None
elif tools_mode == "full":
tools_arg = True
aside_session = SessionState()
aside_instance = state.instance_state
else: # read
assert state.agent.tools is not None
# Read-only clone: keep the main session view so read-only session
# reads (e.g. todo_list) see the real state (they cannot mutate it).
tools_arg = state.agent.tools.clone(read_only_only=True)
aside_session = state.session_state
aside_instance = state.instance_state
async def _run() -> None:
await run_cancellable(
render_events(
state.agent.run_aside(
text,
include_history=not fresh,
tools=tools_arg,
instance_state=aside_instance,
session_state=aside_session,
)
side = render_events(
state.agent.run_aside(
text,
include_history=not fresh,
tools=tools_arg,
instance_state=aside_instance,
session_state=aside_session,
)
)
if tools_mode == "read":
with read_only_context():
await run_cancellable(side)
else:
await run_cancellable(side)
from plyngent.cli.limits import reset_auto_continue_turn
# Aside is its own exchange: a ``yyy`` from the main turn must not leak in.
reset_auto_continue_turn()
try:
_await(_run())
except Exception as exc: # noqa: BLE001 — surface side-turn failures
click.secho(f"error: btw failed: {exc}", fg="red")
return
finally:
reset_auto_continue_turn()
click.secho("(btw done — main session unchanged)", fg="bright_black")
@@ -1119,6 +1155,128 @@ def plugins_slash_cmd(state: ReplState, action: str | None, name: str | None) ->
raise click.UsageError(str(exc)) from exc
_MCP_PREVIEW_MAX_CHARS = 120
def _print_mcp_status(state: ReplState) -> None:
manager = state.mcp_manager
if manager is None:
click.echo("mcp: no servers configured (or none enabled)")
return
rows = manager.statuses()
if not rows:
click.echo("mcp: no servers configured (or none enabled)")
return
for name, status, count in rows:
click.echo(f" {name}\t{status}\ttools={count}")
connection = next(
(conn for conn in manager.connections() if conn.name == name),
None,
)
if connection is not None and connection.instructions:
preview = " ".join(connection.instructions.split())
if len(preview) > _MCP_PREVIEW_MAX_CHARS:
preview = f"{preview[: _MCP_PREVIEW_MAX_CHARS - 3]}..."
click.secho(
f" instructions ({len(connection.instructions)} chars): {preview}",
fg="bright_black",
)
if connection is not None and connection.error is not None:
for line in connection.stderr_tail()[-3:]:
click.secho(f" {line}", fg="bright_black")
@slash.command("mcp")
@click.argument("action", required=False, type=click.Choice(["list", "reconnect"]))
@click.pass_obj
def mcp_slash_cmd(state: ReplState, action: str | None) -> None:
"""List MCP server statuses, or reconnect after config edits.
``/mcp`` or ``/mcp list`` — per-server connection status and tool count.
``/mcp reconnect`` — re-read the config file, restart every enabled
server, and rebuild the tool registry (adopts newly added servers too).
"""
act = (action or "list").lower()
if act == "list":
_print_mcp_status(state)
return
from plyngent.runtime import McpManager
state.config.reload()
if state.mcp_manager is None:
state.mcp_manager = McpManager(state.config.mcp_config)
else:
_await(state.mcp_manager.restart(state.config.mcp_config))
_await(state.mcp_manager.ensure_started())
_slash_rebuild_tools_if_on(state)
click.echo(f"mcp reconnected; tools={'on' if state.tools_enabled else 'off'}")
_print_mcp_status(state)
@slash.command("grants")
@click.argument("action", required=False, type=click.Choice(["list", "revoke"]))
@click.argument("target", required=False)
@click.pass_obj
def grants_cmd(state: ReplState, action: str | None, target: str | None) -> None:
"""List or revoke directory-access grants.
``/grants`` or ``/grants list`` — session, process, then config grants.
``/grants revoke <index|all>`` — drop one grant or all session/process
grants. Config grants come from ``[agent].allow_paths``; edit the TOML.
"""
from plyngent.tools.access import (
access_grant_rows,
clear_process_access,
clear_session_access,
revoke_access,
)
act = (action or "list").lower()
if act == "list":
rows = access_grant_rows(instance=state.instance_state, session=state.session_state)
if not rows:
click.echo("no directory-access grants")
return
click.echo(f"directory-access grants ({len(rows)}):")
for index, (path, mode, source) in enumerate(rows):
click.echo(f" {index}\t{path}\t{mode.name.lower()}\t{source}")
click.echo("revoke with /grants revoke <index> or /grants revoke all")
return
if target is None or not target.strip():
msg = "/grants revoke requires an index or 'all'"
raise click.UsageError(msg)
token = target.strip().lower()
if token == "all":
clear_session_access(session=state.session_state)
clear_process_access(instance=state.instance_state)
_await(state.persist_access_grants())
click.echo("revoked all session/process access grants (config grants unchanged)")
return
if not token.isdigit():
msg = "/grants revoke expects an index or 'all'"
raise click.UsageError(msg)
rows = access_grant_rows(instance=state.instance_state, session=state.session_state)
index = int(token)
if index < 0 or index >= len(rows):
msg = f"no grant at index {index}"
raise click.UsageError(msg)
path, _mode, source = rows[index]
if source == "config":
click.echo(f"config grant {path} is not revocable here; edit [agent].allow_paths")
return
if revoke_access(
path,
source=source,
instance=state.instance_state,
session=state.session_state,
):
_await(state.persist_access_grants())
click.echo(f"revoked {path} ({source})")
else:
click.echo(f"grant not found: {path}")
@slash.command("yolo")
@click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]")
@click.pass_obj
+126 -4
View File
@@ -26,16 +26,31 @@ from plyngent.tools import InstanceState, SessionState
from plyngent.tools.view import MemoryViewStore, session_data_view
if TYPE_CHECKING:
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from plyngent.config.models import Provider
from plyngent.config.store import ConfigStore
from plyngent.memory import MemoryStore
from plyngent.memory.database.schema import Session as SessionRow
from plyngent.runtime.mcp_client import McpManager
from plyngent.tools.access import AccessDecision
from plyngent.tools.workspace import AccessMode
type YoloMode = Literal["off", "on", "once"]
def _parsed_access_grants(raw: Mapping[str, str]) -> dict[Path, AccessMode]:
"""Parse stored ``{path: mode}`` grants into resolved keys (skip bad modes)."""
from plyngent.tools.workspace import parse_access_mode
grants: dict[Path, AccessMode] = {}
for path_str, mode_str in raw.items():
mode = parse_access_mode(mode_str)
if mode is not None:
grants[Path(path_str).expanduser().resolve()] = mode
return grants
@dataclass
class ReplState:
"""Mutable REPL session state."""
@@ -54,6 +69,8 @@ class ReplState:
markdown_enabled: bool = True
# One-shot / scripts: never prompt to raise tool-loop limits.
interactive_limits: bool = True
# Auto-raise tool/PTY limits without prompting (--auto-continue / config).
auto_continue_limits: bool = False
# Soft destructive-tool confirms: None → derive from config.confirm_destructive.
# off = confirm; on = skip (sticky); once = skip next user turn then off.
yolo: YoloMode | None = None
@@ -65,6 +82,8 @@ class ReplState:
todo_stack: TodoStack = field(default_factory=TodoStack)
instance_state: InstanceState = field(default_factory=InstanceState)
session_state: SessionState = field(default_factory=SessionState)
# MCP connections started by the host app before this state was built.
mcp_manager: McpManager | None = None
_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)
@@ -79,6 +98,10 @@ class ReplState:
self.workspace = Path(self.workspace).expanduser().resolve()
self.instance_state.workspace_root = self.workspace
self.instance_state.workspace.root = self.workspace
self._install_config_access()
from plyngent.cli.limits import set_auto_continue_default
set_auto_continue_default(enabled=self.auto_continue_limits)
self.session_state = self._session_data_for_todo()
self.agent = self._make_agent()
self.sync_display_flags()
@@ -90,6 +113,12 @@ class ReplState:
set_verbose_tool_results(self.verbose)
set_markdown_enabled(self.markdown_enabled)
def _reset_limit_auto_continue(self) -> None:
"""Drop a ``yyy`` skip on session change (it is turn-scoped)."""
from plyngent.cli.limits import reset_auto_continue_turn
reset_auto_continue_turn()
def _workspace_key(self) -> str:
key = normalize_workspace(self.workspace)
if key is None:
@@ -145,11 +174,17 @@ class ReplState:
self._todo_persist_tasks.add(task)
task.add_done_callback(self._todo_persist_tasks.discard)
def _session_data_for_todo(self, *, grants: dict[str, bool] | None = None) -> SessionState:
def _session_data_for_todo(
self,
*,
grants: dict[str, bool] | None = None,
access_grants: dict[Path, AccessMode] | None = None,
) -> SessionState:
"""Build a SessionState with live todo + MemoryViewStore seed (durable tree).
*grants* defaults to empty (new session / startup). Pass the previous map
only when intentionally preserving soft-confirm trust across rebinds.
*access_grants* seeds session directory grants (empty clears them).
"""
grant_map = dict(grants or {})
store = MemoryViewStore({"todo": self.todo_stack.to_raw(), "grants": dict(grant_map)})
@@ -158,7 +193,9 @@ class ReplState:
data=session_data_view(store=store),
todo=self.todo_stack,
on_todo_change=self._todo_on_change,
on_access_change=self._on_access_change,
grants=grant_map,
access_grants=dict(access_grants or {}),
)
def _bind_todo_tools(self) -> None:
@@ -200,6 +237,67 @@ class ReplState:
if hasattr(self, "agent"):
self.agent.todo_stack = self.todo_stack
def _on_access_change(self) -> None:
"""Schedule memory persist for live directory-access grants."""
import asyncio
if self.session_id is None:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
task = loop.create_task(self.persist_access_grants())
self._todo_persist_tasks.add(task)
task.add_done_callback(self._todo_persist_tasks.discard)
async def persist_access_grants(self) -> None:
"""Write live session directory grants to the active session row."""
if self.session_id is None:
return
payload = {str(path): mode.name.lower() for path, mode in self.session_state.access_grants.items()}
_ = await self.memory.update_session_access_grants(self.session_id, payload)
async def load_access_grants(self) -> None:
"""Load durable session directory grants into the live map (replace)."""
self.session_state.access_grants.clear()
if self.session_id is None:
return
raw = await self.memory.get_session_access_grants(self.session_id)
self.session_state.access_grants.update(_parsed_access_grants(raw))
def directory_access_confirm_hook(
self,
path: Path,
mode: AccessMode,
reason: str,
timeout_seconds: float,
) -> AccessDecision | str | None:
"""Directory-access confirm: YOLO/``--yes`` auto-approves, else ask."""
from plyngent.cli.limits import prompt_directory_access_confirm
from plyngent.tools.access import AccessDecision as Decision
if self.effective_yolo() != "off":
return Decision(mode, persist=False)
return prompt_directory_access_confirm(path, mode, reason, timeout_seconds)
def _install_config_access(self) -> None:
"""Install ``[agent].allow_paths`` static grants on the policy bag."""
import click
from plyngent.tools.access import set_config_access
_applied, skipped = set_config_access(
self.config.agent_config.allow_paths,
instance=self.instance_state,
)
if skipped:
click.secho(
f"warning: ignored allow_paths entries (missing path or bad mode): {', '.join(skipped)}",
fg="yellow",
err=True,
)
def _tool_registry(self) -> ToolRegistry | None:
if not self.tools_enabled:
return None
@@ -215,6 +313,11 @@ class ReplState:
plugins_cfg.enable,
disable=plugins_cfg.disable,
)
if self.mcp_manager is not None:
from plyngent.tools.mcp import register_mcp_tools
# 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")
yolo = self.effective_yolo() != "off"
@@ -229,16 +332,30 @@ class ReplState:
session_state=self.session_state,
)
def _mcp_instructions_text(self) -> str:
"""Compose connected MCP servers' initialize ``instructions`` into one block.
Only meaningful when tools are on (MCP tools are registered under the
same condition); the text is attributed per server so the model can
weigh its provenance. Gated by ``[agent] mcp_instructions`` in callers.
"""
if self.mcp_manager is None or not self.tools_enabled:
return ""
parts = [f"MCP server {name!r} instructions:\n{text}" for name, text in self.mcp_manager.instructions()]
return "\n\n".join(parts)
def _make_agent(self) -> ChatAgent:
from plyngent.cli.limits import prompt_continue_limit_async
from plyngent.cli.limits import auto_continue_enabled, prompt_continue_limit_async
from plyngent.config import compose_agent_system_content
agent_cfg = self.config.agent_config
mcp_text = self._mcp_instructions_text() if agent_cfg.mcp_instructions else ""
system_prompt = compose_agent_system_content(
agent_cfg.system_prompt,
agent_cfg.tool_directives,
mcp_text,
)
on_limit = prompt_continue_limit_async if self.interactive_limits else None
on_limit = prompt_continue_limit_async if (self.interactive_limits or auto_continue_enabled()) else None
peak = 0
band = 0
last_req = None
@@ -552,6 +669,7 @@ class ReplState:
return False
async def new_session(self, name: str = "chat") -> None:
self._reset_limit_auto_continue()
session = await self.memory.create_session(
name=name,
workspace=self.workspace,
@@ -590,6 +708,7 @@ class ReplState:
"""Load a session; on workspace mismatch, prompt keep / rebind / abort."""
from plyngent.cli.limits import prompt_workspace_mismatch
self._reset_limit_auto_continue()
row = await self.memory.get_session(session_id)
if row is None:
msg = f"session not found: {session_id}"
@@ -623,9 +742,11 @@ class ReplState:
self.agent = self._make_agent()
await self.agent.load_history()
await self.load_todo_stack()
await self.load_access_grants()
async def resume_latest_or_new(self, name: str = "chat") -> str:
"""Resume most recently updated session for this workspace, or create one."""
self._reset_limit_auto_continue()
latest = await self.memory.get_latest_session(workspace=self.workspace)
if latest is None:
await self.new_session(name=name)
@@ -638,6 +759,7 @@ class ReplState:
self.agent = self._make_agent()
await self.agent.load_history()
await self.load_todo_stack()
await self.load_access_grants()
_ = await self.memory.touch_session(latest.sid)
return "resume"
+2
View File
@@ -13,6 +13,8 @@ from .models import DatabaseConfig as DatabaseConfig
from .models import DeepSeekConvention as DeepSeekConvention
from .models import DeepseekProvider as DeepseekProvider
from .models import HttpTimeoutConfig as HttpTimeoutConfig
from .models import McpConfig as McpConfig
from .models import McpServerConfig as McpServerConfig
from .models import ModelConfig as ModelConfig
from .models import NetworkingConfig as NetworkingConfig
from .models import OpenAICompatibleProvider as OpenAICompatibleProvider
+59 -11
View File
@@ -10,6 +10,9 @@ from msgspec import Struct, field
# ``https://api.deepseek.com/anthropic``)
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 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).
@@ -23,6 +26,8 @@ DEFAULT_TOOL_DIRECTIVES = """\
### Workspace
- Explore with `tree`/`listdir`/`glob_paths`/`grep_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.
### Files
- Prefer file tools over shell. `edit_replace` fails usually means a bad match — \
@@ -70,19 +75,15 @@ call `todo_clear` (or pop finished TOP groups) so no hygiene noise is left behin
"""
def compose_agent_system_content(
system_prompt: str,
tool_directives: str,
) -> str | None:
"""Join persona + tool playbook into one system body, or ``None`` if both empty.
def compose_agent_system_content(*parts: str) -> str | None:
"""Join persona + tool playbook + extra blocks into one system body.
Non-empty parts are stripped and joined with a blank line. Either field may
be ``""`` to disable that part while keeping the other.
Returns ``None`` when every part is empty. Non-empty parts are stripped and
joined with a blank line. Any part may be ``""`` to disable that block
(e.g. ``system_prompt = ""`` keeps the tool playbook).
"""
parts = [part.strip() for part in (system_prompt, tool_directives) if part and part.strip()]
if not parts:
return None
return "\n\n".join(parts)
joined = "\n\n".join(part.strip() for part in parts if part and part.strip())
return joined or None
class DatabaseConfig(Struct, omit_defaults=True):
@@ -106,10 +107,19 @@ class AgentConfig(Struct, omit_defaults=True):
system_prompt: str = DEFAULT_SYSTEM_PROMPT
# Tool how-to (omit → DEFAULT_TOOL_DIRECTIVES; "" disables playbook only).
tool_directives: str = DEFAULT_TOOL_DIRECTIVES
# Fold connected MCP servers' initialize ``instructions`` (usage guidance)
# into the agent system prompt when tools are on. false = ignore server text.
mcp_instructions: bool = True
max_tool_result_chars: int = 32_000
parallel_tools: bool = True
confirm_destructive: bool = True
path_denylist: list[str] = field(default_factory=list)
# Static directory pre-allow (resolved path → mode); never prompts.
# Denylist still applies, and the path must exist when chat starts.
allow_paths: dict[str, AllowPathMode] = field(default_factory=dict)
# Auto-raise tool/PTY limits without prompting (same as answering ``yyy``);
# process-wide, so every turn skips the limit confirm.
auto_continue_limits: bool = False
max_context_tokens: int = 200_000
# How to inject todo stack nags into model context (see agent/todo_nag.py).
@@ -143,6 +153,44 @@ class PluginsConfig(Struct, omit_defaults=True):
disable: list[str] = field(default_factory=list)
class McpServerConfig(Struct, omit_defaults=True):
"""One MCP server definition (stdio transport today).
TOML example::
[mcp.servers.docs]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
env = { RUST_LOG = "info" }
# cwd = "/path" # spawn working directory (empty = inherit)
# timeout = 30.0 # per-request timeout in seconds
# read_only = true # mark every tool READ_ONLY (safe side turns)
``url`` is reserved for the streamable-HTTP transport; empty = stdio.
A defined server is connected unless its name appears in ``[mcp].disable``.
"""
command: str = ""
args: list[str] = field(default_factory=list)
env: dict[str, str] = field(default_factory=dict)
cwd: str = ""
timeout: float = 30.0
read_only: bool = False
url: str = ""
class McpConfig(Struct, omit_defaults=True):
"""Model Context Protocol servers exposed as agent tools.
Defining a server under ``[mcp.servers.<name>]`` is the opt-in; tools are
namespaced ``mcp__<server>__<tool>`` and registered with LOCAL tags. Names
in ``disable`` stay disconnected even when defined.
"""
servers: dict[str, McpServerConfig] = field(default_factory=dict)
disable: list[str] = field(default_factory=list)
class NetworkingConfig(Struct, omit_defaults=True):
"""Host-side network policy for tools such as ``fetch``.
+34
View File
@@ -9,6 +9,7 @@ import tomlkit
from .models import (
AgentConfig,
DatabaseConfig,
McpConfig,
ModelConfig,
NetworkingConfig,
PluginsConfig,
@@ -56,6 +57,29 @@ def _parse_networking(raw: dict[str, object]) -> NetworkingConfig:
return NetworkingConfig()
def _parse_mcp(raw: dict[str, object]) -> McpConfig:
"""Parse the ``[mcp]`` section, falling back to defaults.
Unknown keys (typos like ``cmds``) are rejected the same way as providers:
a malformed section silently degrades to "no MCP servers" instead of a
half-parsed one.
"""
try:
config = msgspec.convert(raw, McpConfig)
except msgspec.ValidationError:
return McpConfig()
known = {"servers", "disable"}
if set(raw) - known:
return McpConfig()
known_server = {"command", "args", "env", "cwd", "timeout", "read_only", "url"}
servers_raw = cast("dict[str, object]", raw.get("servers", {}))
for name, raw_entry in servers_raw.items():
entry = cast("dict[str, object]", raw_entry)
if not isinstance(raw_entry, dict) or not name.strip() or set(entry) - known_server:
return McpConfig()
return config
def _parse_providers(
document: tomlkit.TOMLDocument,
) -> tuple[dict[str, Provider], dict[str, object], dict[str, Provider]]:
@@ -125,6 +149,7 @@ class ConfigStore:
_agent: AgentConfig
_plugins: PluginsConfig
_networking: NetworkingConfig
_mcp: McpConfig
_providers: dict[str, Provider]
_bad_providers: dict[str, object]
_recoverable_providers: dict[str, Provider]
@@ -137,6 +162,7 @@ class ConfigStore:
self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {})))
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._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document)
@property
@@ -187,6 +213,13 @@ class ConfigStore:
"""Typed networking section (e.g. Fake-IP SSRF exemptions)."""
return self._networking
# -- mcp (read-only) --
@property
def mcp_config(self) -> McpConfig:
"""Typed MCP section (server definitions + disable list)."""
return self._mcp
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()]
@@ -390,6 +423,7 @@ class ConfigStore:
self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {})))
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._providers, self._bad_providers, self._recoverable_providers = _parse_providers(self._document)
# -- internal sync helpers --
+2
View File
@@ -34,6 +34,8 @@ class Session(PlyngentBase):
model: Mapped[str | None] = mapped_column(String(256), nullable=True)
# Todo/task stack JSON for multi-step sub-tasks (optional).
todo_stack: Mapped[dict[str, object] | None] = mapped_column(JSON(), nullable=True)
# Directory-access grants approved for this session (resolved path → mode).
access_grants: Mapped[dict[str, str] | None] = mapped_column(JSON(), nullable=True)
# Last model request context size (API prompt_tokens preferred).
last_prompt_tokens: Mapped[int | None] = mapped_column(nullable=True)
peak_prompt_tokens: Mapped[int | None] = mapped_column(nullable=True)
+39 -2
View File
@@ -241,6 +241,31 @@ class MemoryStore:
await session.refresh(row)
return row
async def get_session_access_grants(self, sid: int) -> dict[str, str]:
"""Return stored directory-access grants (resolved path → mode)."""
async with self._session_factory() as session:
row = await session.get(Session, sid)
if row is None:
msg = f"session not found: {sid}"
raise ValueError(msg)
raw = row.access_grants
if raw is None:
return {}
return {str(k): str(v) for k, v in cast("dict[object, object]", raw).items()}
async def update_session_access_grants(self, sid: int, grants: Mapping[str, str]) -> Session:
"""Persist directory-access grants for a session (empty clears)."""
async with self._session_factory() as session:
row = await session.get(Session, sid)
if row is None:
msg = f"session not found: {sid}"
raise ValueError(msg)
row.access_grants = dict(grants)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return row
async def update_session_context_usage(
self,
sid: int,
@@ -400,18 +425,30 @@ def _migrate_session_v3(sync_conn: object, columns: set[str]) -> None:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN reminder_last_band INTEGER"))
def _migrate_session_v4(sync_conn: object, columns: set[str]) -> None:
"""Post-v0.5.0: add ``access_grants`` JSON for session directory-access grants."""
from sqlalchemy.engine import Connection
if not isinstance(sync_conn, Connection):
return
if "access_grants" not in columns:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN access_grants JSON"))
# Latest schema version this build knows. Bump (and append a step to
# _SCHEMA_MIGRATIONS) when a new release adds or changes columns.
_SCHEMA_VERSION = 3
_SCHEMA_VERSION = 4
# Ordered (version, step) pairs. Each step receives the current ``session``
# column set (read once) and adds only its own missing columns, so it is
# idempotent for DBs that were upgraded incrementally without a version stamp.
# Version 1 shipped in v0.1.0, version 2 in v0.1.2, version 3 post-v0.2.0.
# Version 1 shipped in v0.1.0, version 2 in v0.1.2, version 3 post-v0.2.0,
# version 4 post-v0.5.0.
_SCHEMA_MIGRATIONS: tuple[tuple[int, MigrationStep], ...] = (
(1, _migrate_session_v1),
(2, _migrate_session_v2),
(3, _migrate_session_v3),
(4, _migrate_session_v4),
)
+6
View File
@@ -1,5 +1,11 @@
from plyngent.config.models import McpConfig as McpConfig
from .client_factory import InvalidHttpTimeoutError as InvalidHttpTimeoutError
from .client_factory import ProviderNotSupportedError as ProviderNotSupportedError
from .client_factory import create_client as create_client
from .client_factory import normalize_http_timeout as normalize_http_timeout
from .client_factory import provider_to_openai_config as provider_to_openai_config
from .mcp_client import McpConnectionError as McpConnectionError
from .mcp_client import McpManager as McpManager
from .mcp_client import McpServerConnection as McpServerConnection
from .mcp_client import McpTool as McpTool
+383
View File
@@ -0,0 +1,383 @@
"""MCP client: Model Context Protocol over stdio (newline-delimited JSON-RPC).
One :class:`McpServerConnection` owns one spawned server subprocess and its
initialize handshake. :class:`McpManager` owns all configured connections for
the process: start, tool listing, tool calls, reconnect, shutdown.
"""
from __future__ import annotations
import asyncio
import importlib.metadata
import json
import os
from collections import deque
from dataclasses import dataclass
from typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from plyngent.config.models import McpConfig, McpServerConfig
MCP_PROTOCOL_VERSION = "2025-06-18"
MCP_CLIENT_NAME = "plyngent"
DEFAULT_MCP_TIMEOUT = 30.0
_STDERR_TAIL_LINES = 32
_CLOSE_GRACE_SECONDS = 2.0
_MAX_TOOL_PAGES = 1024
type Json = dict[str, object]
def _json_dict(raw: object) -> Json:
"""Narrow an untyped JSON payload to a string-keyed object mapping."""
return cast("Json", raw) if isinstance(raw, dict) else {}
def _json_list(raw: object) -> list[object]:
"""Narrow an untyped JSON payload to a list of objects."""
return cast("list[object]", raw) if isinstance(raw, list) else []
def _client_version() -> str:
try:
return importlib.metadata.version("plyngent")
except importlib.metadata.PackageNotFoundError:
return "0.0.0"
class McpConnectionError(RuntimeError):
"""Server process failed to spawn, handshake, or answer in time."""
@dataclass(frozen=True, slots=True)
class McpTool:
"""One tool advertised by a server (``tools/list`` entry)."""
name: str
description: str
input_schema: Json
@classmethod
def from_raw(cls, raw: object) -> McpTool:
entry = _json_dict(raw)
return cls(
name=str(entry.get("name", "")),
description=str(entry.get("description", "") or ""),
input_schema=_json_dict(entry.get("inputSchema")) or {"type": "object"},
)
def _format_content(result: Json) -> str:
"""Flatten a ``tools/call`` result payload into model-facing text."""
parts: list[str] = []
for item in _json_list(result.get("content")):
entry = _json_dict(item)
kind = str(entry.get("type", ""))
if kind == "text":
text = entry.get("text")
if isinstance(text, str):
parts.append(text)
elif kind == "resource":
parts.append(f"[resource] {json.dumps(entry, ensure_ascii=False)}")
else:
mime = entry.get("mimeType", "?")
parts.append(f"[{kind or 'unknown'}: {mime}]")
return "\n".join(parts)
class McpServerConnection:
"""One server subprocess with an active MCP session (stdio transport)."""
name: str
error: str | None
tools: list[McpTool]
tools_stale: bool
# Server usage guidance from the initialize result (InitializeResult.instructions).
instructions: str | None
_config: McpServerConfig
_proc: asyncio.subprocess.Process | None
_reader_task: asyncio.Task[None] | None
_pending: dict[int, asyncio.Future[Json]]
_next_id: int
_stderr_tail: deque[str]
def __init__(self, name: str, config: McpServerConfig) -> None:
self.name = name
self._config = config
self._proc = None
self._reader_task = None
self._pending = {}
self._next_id = 1
self._stderr_tail = deque(maxlen=_STDERR_TAIL_LINES)
self.error = None
self.tools = []
self.tools_stale = False
self.instructions = None
@property
def status(self) -> str:
if self.error is not None:
return f"error: {self.error}"
if self._proc is not None:
return "connected (tools changed; /mcp reconnect)" if self.tools_stale else "connected"
return "not started"
@property
def connected(self) -> bool:
return self._proc is not None and self.error is None
def stderr_tail(self) -> list[str]:
return list(self._stderr_tail)
async def start(self) -> None:
"""Spawn the server process and run the initialize handshake."""
if self._proc is not None:
return
try:
self._proc = await asyncio.create_subprocess_exec(
self._config.command,
*self._config.args,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=os.environ | self._config.env,
cwd=self._config.cwd or None,
)
except (OSError, ValueError) as exc:
self.error = f"spawn {self._config.command!r}: {exc}"
self._proc = None
return
self._reader_task = asyncio.create_task(self._read_loop())
try:
await self._initialize()
self.tools = await self.list_tools()
except (McpConnectionError, TimeoutError) as exc:
self.error = f"initialize: {exc}"
await self.aclose()
async def aclose(self) -> None:
"""Terminate the server subprocess and fail pending requests."""
proc = self._proc
self._proc = None
if self._reader_task is not None:
_ = self._reader_task.cancel()
self._reader_task = None
if proc is not None and proc.returncode is None:
_ = proc.terminate()
try:
_ = await asyncio.wait_for(proc.wait(), _CLOSE_GRACE_SECONDS)
except TimeoutError:
proc.kill()
_ = await proc.wait()
for future in self._pending.values():
if not future.done():
future.set_exception(McpConnectionError("connection closed"))
self._pending.clear()
async def _read_loop(self) -> None:
proc = self._proc
if proc is None or proc.stdout is None or proc.stderr is None:
return
stderr_task = asyncio.create_task(self._drain_stderr(proc.stderr))
try:
while True:
line = await proc.stdout.readline()
if not line:
break
self._dispatch(line)
finally:
_ = stderr_task.cancel()
for future in self._pending.values():
if not future.done():
future.set_exception(McpConnectionError("server closed the stream"))
self._pending.clear()
async def _drain_stderr(self, stderr: asyncio.StreamReader) -> None:
while True:
line = await stderr.readline()
if not line:
return
self._stderr_tail.append(line.decode(errors="replace").rstrip())
def _dispatch(self, line: bytes) -> None:
try:
payload = _json_dict(cast("object", json.loads(line)))
except json.JSONDecodeError:
return
message_id = payload.get("id")
if message_id is not None:
if isinstance(message_id, bool) or not isinstance(message_id, (int, str)):
return
request_id = int(message_id)
future = self._pending.pop(request_id, None)
if future is None or future.done():
return
error = payload.get("error")
if error is not None:
err = _json_dict(error)
detail = str(err.get("message", "unknown error")) if err else str(error)
future.set_exception(McpConnectionError(detail))
else:
future.set_result(_json_dict(payload.get("result")))
return
# Notifications carry no id; only tool-list changes matter to the host.
if payload.get("method") == "notifications/tools/list_changed":
self.tools_stale = True
def _send(self, payload: Json) -> None:
proc = self._proc
stdin = proc.stdin if proc is not None else None
if stdin is None:
msg = "server process is not running"
raise McpConnectionError(msg)
_ = stdin.write(json.dumps(payload, ensure_ascii=False).encode() + b"\n")
async def _request(self, method: str, params: Json | None = None) -> Json:
timeout = self._config.timeout or DEFAULT_MCP_TIMEOUT
future: asyncio.Future[Json] = asyncio.get_running_loop().create_future()
request_id = self._next_id
self._next_id += 1
self._pending[request_id] = future
payload: Json = {"jsonrpc": "2.0", "id": request_id, "method": method}
if params is not None:
payload["params"] = params
try:
self._send(payload)
except McpConnectionError:
_ = self._pending.pop(request_id, None)
raise
try:
return await asyncio.wait_for(future, timeout)
except TimeoutError:
_ = self._pending.pop(request_id, None)
msg = f"{method} timed out after {timeout}s"
raise McpConnectionError(msg) from None
async def _notify(self, method: str, params: Json | None = None) -> None:
payload: Json = {"jsonrpc": "2.0", "method": method}
if params is not None:
payload["params"] = params
self._send(payload)
async def _initialize(self) -> None:
result = await self._request(
"initialize",
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": MCP_CLIENT_NAME, "version": _client_version()},
},
)
# Optional server usage guidance (MCP spec: InitializeResult.instructions).
# Hosts surface this to the model when the server's tools are in play.
instructions = result.get("instructions")
self.instructions = instructions.strip() if isinstance(instructions, str) and instructions.strip() else None
await self._notify("notifications/initialized")
async def list_tools(self) -> list[McpTool]:
"""Fetch the full tool list, following ``nextCursor`` pagination."""
tools: list[McpTool] = []
cursor: str | None = None
for _ in range(_MAX_TOOL_PAGES):
params: Json = {} if cursor is None else {"cursor": cursor}
result = await self._request("tools/list", params)
tools.extend(McpTool.from_raw(item) for item in _json_list(result.get("tools")))
next_cursor = result.get("nextCursor")
if not isinstance(next_cursor, str) or not next_cursor:
return tools
cursor = next_cursor
return tools
async def call_tool(self, name: str, arguments: Json) -> str:
"""Run ``tools/call`` and return the flattened text result.
Tool-level failures (``isError``) come back as ``error: …`` text, not
exceptions: the protocol round-trip itself succeeded.
"""
result = await self._request("tools/call", {"name": name, "arguments": arguments})
text = _format_content(result)
if result.get("isError") is True:
return f"error: {text or 'tool reported failure without content'}"
return text
class McpManager:
"""All configured MCP server connections for this process."""
_config: McpConfig
_connections: dict[str, McpServerConnection]
def __init__(self, config: McpConfig) -> None:
self._config = config
self._connections = {}
def enabled_servers(self) -> dict[str, McpServerConfig]:
"""Configured servers minus the ``disable`` list, in config order."""
disabled = {name.strip() for name in self._config.disable}
return {name: cfg for name, cfg in self._config.servers.items() if name not in disabled}
async def ensure_started(self) -> None:
"""Connect every enabled server that has no live connection yet."""
for name, cfg in self.enabled_servers().items():
connection = self._connections.get(name)
if connection is not None and connection.connected:
continue
if connection is not None:
await connection.aclose()
connection = McpServerConnection(name, cfg)
self._connections[name] = connection
await connection.start()
async def restart(self, config: McpConfig) -> None:
"""Drop every connection and adopt a new config (e.g. /mcp reconnect)."""
await self.aclose()
self._config = config
async def aclose(self) -> None:
for connection in self._connections.values():
await connection.aclose()
self._connections.clear()
def statuses(self) -> list[tuple[str, str, int]]:
"""(server, status, tool_count) rows for diagnostics surfaces."""
rows: list[tuple[str, str, int]] = []
for name in self.enabled_servers():
connection = self._connections.get(name)
if connection is None:
rows.append((name, "not started", 0))
else:
rows.append((name, connection.status, len(connection.tools)))
return rows
def instructions(self) -> list[tuple[str, str]]:
"""(server, text) initialize ``instructions`` for every connected server.
Only non-empty guidance is returned, in config order. Hosts may fold
these into the agent system context (see cli.state.ReplState).
"""
rows: list[tuple[str, str]] = []
for name in self.enabled_servers():
connection = self._connections.get(name)
if connection is not None and connection.connected:
text = (connection.instructions or "").strip()
if text:
rows.append((name, text))
return rows
def connections(self) -> list[McpServerConnection]:
return list(self._connections.values())
async def refresh_tools(self) -> None:
"""Re-run tools/list on every connected server."""
for connection in self._connections.values():
if connection.connected:
connection.tools = await connection.list_tools()
connection.tools_stale = False
async def call(self, server: str, tool: str, arguments: Json) -> str:
connection = self._connections.get(server)
if connection is None or not connection.connected:
msg = f"server {server!r} is not connected"
raise McpConnectionError(msg)
return await connection.call_tool(tool, arguments)
+20
View File
@@ -1,3 +1,17 @@
from .access import ACCESS_TOOLS as ACCESS_TOOLS
from .access import MAX_ACCESS_GRANTS as MAX_ACCESS_GRANTS
from .access import AccessDecision as AccessDecision
from .access import access_grant_rows as access_grant_rows
from .access import active_grant_count as active_grant_count
from .access import clear_process_access as clear_process_access
from .access import clear_session_access as clear_session_access
from .access import get_directory_access_confirm_hook as get_directory_access_confirm_hook
from .access import grant_process_access as grant_process_access
from .access import grant_session_access as grant_session_access
from .access import request_directory_access as request_directory_access
from .access import revoke_access as revoke_access
from .access import set_config_access as set_config_access
from .access import set_directory_access_confirm_hook as set_directory_access_confirm_hook
from .catalog import ToolCatalog as ToolCatalog
from .catalog import ToolSource as ToolSource
from .catalog import catalog_scope as catalog_scope
@@ -66,6 +80,7 @@ from .workspace import (
DEFAULT_COMMAND_DENYLIST as DEFAULT_COMMAND_DENYLIST,
)
from .workspace import DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS as DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS
from .workspace import AccessMode as AccessMode
from .workspace import WorkspaceError as WorkspaceError
from .workspace import WorkspacePolicy as WorkspacePolicy
from .workspace import active_workspace_policy as active_workspace_policy
@@ -74,12 +89,16 @@ from .workspace import check_command_allowed as check_command_allowed
from .workspace import clear_policy_allowed_commands as clear_policy_allowed_commands
from .workspace import clear_workspace_allowlist as clear_workspace_allowlist
from .workspace import clear_workspace_root as clear_workspace_root
from .workspace import denylist_match as denylist_match
from .workspace import get_command_denylist as get_command_denylist
from .workspace import get_path_denylist as get_path_denylist
from .workspace import get_policy_confirm_timeout as get_policy_confirm_timeout
from .workspace import get_workspace_root as get_workspace_root
from .workspace import grant_policy_command as grant_policy_command
from .workspace import granted_access_mode as granted_access_mode
from .workspace import list_workspace_allowlist as list_workspace_allowlist
from .workspace import mode_covers as mode_covers
from .workspace import parse_access_mode as parse_access_mode
from .workspace import pop_owned_temporary_workspaces as pop_owned_temporary_workspaces
from .workspace import remove_workspace_allowlist as remove_workspace_allowlist
from .workspace import resolve_path as resolve_path
@@ -88,3 +107,4 @@ from .workspace import set_path_denylist as set_path_denylist
from .workspace import set_policy_confirm_hook as set_policy_confirm_hook
from .workspace import set_policy_confirm_timeout as set_policy_confirm_timeout
from .workspace import set_workspace_root as set_workspace_root
from .workspace import within_workspace_roots as within_workspace_roots
+286
View File
@@ -0,0 +1,286 @@
"""Directory-access grants: request tool plus config / session / process stores.
A grant is one ``path → mode`` pair; its *source* is structural:
- :attr:`WorkspacePolicy.config_allow` — static TOML pre-allow (never prompts),
- :attr:`SessionState.access_grants` — human-approved for this session,
- :attr:`WorkspacePolicy.yolo_allow` — ``--yes`` / YOLO auto-approval, process only.
``resolve_path`` consults all three. Grants are a policy gate for path-resolving
tools, **not** a sandbox: an ``exec`` grant (or any command) can reach the rest
of the filesystem.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple
from plyngent.agent import ToolTag, tool
from plyngent.tools.context import get_session, require_session
from plyngent.tools.workspace import (
AccessMode,
denylist_match,
get_policy_confirm_timeout,
granted_access_mode,
mode_covers,
parse_access_mode,
require_bound_instance,
within_workspace_roots,
)
if TYPE_CHECKING:
from collections.abc import Callable, Mapping
from plyngent.tools.context import InstanceState, SessionState
# Max active grants across all three stores (bounds prompt/listing noise).
MAX_ACCESS_GRANTS = 32
_HOOK_KEY = "directory_access_confirm_hook"
class AccessDecision(NamedTuple):
"""Hook approval for one directory-access request.
``mode`` is the granted level (may differ from the requested one);
``persist`` is true for a session grant, false for a process-only grant.
"""
mode: AccessMode
persist: bool = True
type DirectoryAccessConfirmHook = Callable[[Path, AccessMode, str, float], AccessDecision | str | None]
def get_directory_access_confirm_hook(instance: InstanceState | None = None) -> DirectoryAccessConfirmHook | None:
"""Return the host-installed confirm hook for this instance, if any."""
inst = instance if instance is not None else require_bound_instance()
hook = inst.extras.get(_HOOK_KEY)
if hook is None or not callable(hook):
return None
def _as_hook(path: Path, mode: AccessMode, reason: str, timeout: float) -> AccessDecision | str | None:
result: object = hook(path, mode, reason, timeout)
if isinstance(result, AccessDecision | str):
return result
return None
return _as_hook
def set_directory_access_confirm_hook(
hook: DirectoryAccessConfirmHook | None,
*,
instance: InstanceState | None = None,
) -> None:
"""Install (or clear) the confirm hook the CLI uses to ask the human."""
inst = instance if instance is not None else require_bound_instance()
inst.extras[_HOOK_KEY] = hook
def _notify_access_change(session: SessionState | None) -> None:
"""Fire the host's persist hook after the live session map changes."""
if session is not None and session.on_access_change is not None:
session.on_access_change()
def grant_session_access(
path: Path | str,
mode: AccessMode,
*,
session: SessionState | None = None,
) -> Path:
"""Grant *mode* on *path* for the bound session; returns the resolved path."""
resolved = Path(path).expanduser().resolve()
sess = session if session is not None else require_session()
sess.access_grants[resolved] = mode
_notify_access_change(sess)
return resolved
def grant_process_access(path: Path | str, mode: AccessMode) -> Path:
"""Grant *mode* on *path* for this process only (``--yes``); never persisted."""
resolved = Path(path).expanduser().resolve()
require_bound_instance().workspace.yolo_allow[resolved] = mode
return resolved
def clear_process_access(*, instance: InstanceState | None = None) -> None:
"""Drop process-scoped (``--yes``) grants."""
inst = instance if instance is not None else require_bound_instance()
inst.workspace.yolo_allow.clear()
def clear_session_access(*, session: SessionState | None = None) -> None:
"""Drop live session grants (durable copy is cleared by the host)."""
sess = session if session is not None else get_session()
if sess is not None:
sess.access_grants.clear()
_notify_access_change(sess)
def active_grant_count() -> int:
"""Total live grants across config, session, and process stores."""
policy = require_bound_instance().workspace
total = len(policy.config_allow) + len(policy.yolo_allow)
session = get_session()
if session is not None:
total += len(session.access_grants)
return total
def set_config_access(
entries: Mapping[str, str],
*,
instance: InstanceState | None = None,
) -> tuple[list[Path], list[str]]:
"""Install static TOML pre-allow entries on the instance policy bag.
Replaces any previous config grants. Returns ``(applied roots, skipped
keys)``; entries are skipped when the mode is unknown or the path does not
exist (the host warns about skipped keys).
"""
inst = instance if instance is not None else require_bound_instance()
policy = inst.workspace
policy.config_allow.clear()
applied: list[Path] = []
skipped: list[str] = []
for raw_path, raw_mode in entries.items():
mode = parse_access_mode(raw_mode)
resolved: Path | None
try:
resolved = Path(raw_path).expanduser().resolve()
except OSError:
resolved = None
if mode is None or resolved is None or not resolved.exists():
skipped.append(raw_path)
continue
policy.config_allow[resolved] = mode
applied.append(resolved)
return applied, skipped
def access_grant_rows(
*,
instance: InstanceState | None = None,
session: SessionState | None = None,
) -> list[tuple[Path, AccessMode, str]]:
"""``(path, mode, source)`` rows: session, then process, then config."""
inst = instance if instance is not None else require_bound_instance()
sess = session if session is not None else get_session()
rows: list[tuple[Path, AccessMode, str]] = []
if sess is not None:
rows.extend((path, mode, "session") for path, mode in sess.access_grants.items())
rows.extend((path, mode, "process") for path, mode in inst.workspace.yolo_allow.items())
rows.extend((path, mode, "config") for path, mode in inst.workspace.config_allow.items())
return rows
def revoke_access(
path: Path | str,
*,
source: str,
instance: InstanceState | None = None,
session: SessionState | None = None,
) -> bool:
"""Remove the exact resolved *path* from one store; false if not present.
``config`` grants are owned by ``[agent].allow_paths`` and are not removed
here (the caller should point the human at the config file).
"""
resolved = Path(path).expanduser().resolve()
if source == "session":
sess = session if session is not None else get_session()
if sess is None or sess.access_grants.pop(resolved, None) is None:
return False
_notify_access_change(sess)
return True
if source == "process":
inst = instance if instance is not None else require_bound_instance()
return inst.workspace.yolo_allow.pop(resolved, None) is not None
return False
def _resolve_target(path: str) -> Path | str:
"""Resolve a requested path; return a model-facing error string on failure."""
try:
target = Path(path).expanduser().resolve()
except OSError as exc:
return f"error: cannot resolve path {path!r}: {exc}"
if not target.exists():
return f"error: path does not exist: {target}"
return target
def _precheck_target(target: Path, requested: AccessMode) -> str | None:
"""Return a short-circuit message when no prompt is needed, else ``None``."""
if requested is AccessMode.EXEC and not target.is_dir():
return f"error: exec access requires a directory: {target}"
matched = denylist_match(target, directory=target.is_dir())
if matched is not None:
return f"error: path denied by policy (matched {matched!r}); it can never be granted: {target}"
if within_workspace_roots(target):
return f"already accessible: {target} is inside the workspace"
granted = granted_access_mode(target)
if granted is not None and mode_covers(granted, requested):
return f"already granted: {target} ({granted.name.lower()})"
if active_grant_count() >= MAX_ACCESS_GRANTS:
return f"error: too many active access grants (max {MAX_ACCESS_GRANTS}); revoke some first"
return None
def _apply_decision(target: Path, decision: AccessDecision | str | None, timeout: float) -> str:
"""Record an approved grant or format the denial for the model."""
if isinstance(decision, AccessDecision):
if decision.persist and get_session() is not None:
_ = grant_session_access(target, decision.mode)
scope = "session"
else:
_ = grant_process_access(target, decision.mode)
scope = "process"
return (
f"access granted: {target} ({decision.mode.name.lower()}, {scope})\n"
"note: the path denylist still applies; the human can revoke with /grants"
)
if isinstance(decision, str) and decision.strip():
return f"error: access to {target} denied by user; user comment: {decision.strip()}"
return f"error: access to {target} denied (declined or timed out after {timeout:g}s)"
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.SESSION_STATE)
async def request_directory_access(path: str, reason: str = "", mode: str = "read") -> str:
"""Request human approval to use a directory (or exact file) outside the workspace.
Call this instead of retrying a path that failed with ``path escapes
workspace root``. ``mode`` is ``read`` (inspect files), ``write`` (also
modify files) or ``exec`` (also use it as ``run_argv`` / PTY cwd); the human
may approve a different level. Approval lasts for the session (or for this
process when ``--yes`` auto-approves). Paths matching the configured
denylist can never be granted.
"""
requested = parse_access_mode(mode)
if requested is None:
return f"error: invalid mode {mode!r} (use read, write, or exec)"
resolved = _resolve_target(path)
if isinstance(resolved, str):
return resolved
target = resolved
precheck = _precheck_target(target, requested)
if precheck is not None:
return precheck
hook = get_directory_access_confirm_hook()
if hook is None:
return f"error: access to {target} requires a human confirm (no confirm hook installed; denied)"
timeout = get_policy_confirm_timeout()
try:
decision = hook(target, requested, reason, timeout)
except Exception as exc: # noqa: BLE001 — surface confirm failures to the model
return f"error: access confirm for {target} failed: {exc}"
return _apply_decision(target, decision, timeout)
ACCESS_TOOLS = [
request_directory_access,
]
+12 -3
View File
@@ -12,7 +12,7 @@ from plyngent.agent.tools import ToolDefinition, ToolTag
if TYPE_CHECKING:
from collections.abc import Container, Generator
type ToolSourceKind = Literal["builtin", "plugin"]
type ToolSourceKind = Literal["builtin", "plugin", "mcp"]
type ToolSurface = Literal["local", "public"]
@@ -29,8 +29,8 @@ class ToolSource:
def __str__(self) -> str:
if self.kind == "builtin":
return "builtin"
plugin = self.plugin_id or "?"
return f"plugin:{plugin}"
id_part = self.plugin_id or "?"
return f"{self.kind}:{id_part}"
@dataclass(frozen=True, slots=True)
@@ -152,6 +152,13 @@ class ToolCatalog:
def clear(self) -> None:
self._by_name.clear()
def purge_kind(self, kind: ToolSourceKind) -> int:
"""Remove every entry registered from *kind*; return the count removed."""
doomed = [name for name, entry in self._by_name.items() if entry.source.kind == kind]
for name in doomed:
del self._by_name[name]
return len(doomed)
def snapshot(self) -> dict[str, RegisteredTool]:
return dict(self._by_name)
@@ -211,6 +218,7 @@ def _ensure_builtin_definitions_registered(catalog: ToolCatalog) -> None:
import time (usually the process catalog). Test ``catalog_scope(empty=True)``
overrides must still see builtins for ``default_tool_definitions``.
"""
from plyngent.tools.access import ACCESS_TOOLS
from plyngent.tools.chat import CHAT_TOOLS
from plyngent.tools.file import FILE_TOOLS
from plyngent.tools.net import NET_TOOLS
@@ -219,6 +227,7 @@ def _ensure_builtin_definitions_registered(catalog: ToolCatalog) -> None:
from plyngent.tools.vcs import VCS_TOOLS
for definition in (
*ACCESS_TOOLS,
*FILE_TOOLS,
*PROCESS_TOOLS,
*VCS_TOOLS,
+1 -1
View File
@@ -4,7 +4,7 @@ from plyngent.agent import ToolTag, tool
from plyngent.prompting import NonInteractiveError, ask_async
@tool(name="ask_user_line", tags=ToolTag.LOCAL)
@tool(name="ask_user_line", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
async def ask_user(question: str, default: str = "") -> str:
"""Ask the human a free-form one-line question and return their answer.
+1 -1
View File
@@ -36,7 +36,7 @@ def parse_options(options: list[object]) -> list[ChoiceOption]:
return out
@tool(name="ask_user_choice", tags=ToolTag.LOCAL)
@tool(name="ask_user_choice", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
async def choose_user(
question: str,
options: list[str] | list[dict[str, object]],
+1 -1
View File
@@ -46,7 +46,7 @@ def parse_fields(fields: list[dict[str, object]]) -> list[FormField]:
return out
@tool(name="ask_user_form", tags=ToolTag.LOCAL)
@tool(name="ask_user_form", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
async def form_user(
title: str,
fields: list[dict[str, object]],
+30 -11
View File
@@ -1,20 +1,44 @@
from __future__ import annotations
import asyncio
import re
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
WAIT_PROMPT = "Waiting for {duration}s. Press Enter to disturb. Feel free to ship any reason before pressing Enter. "
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")
@tool(name="wait", tags=ToolTag.LOCAL)
def _wait_prompt(duration: int, *, 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
echo ANSI stripping), so styling is applied here and stripped when stdout
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")
if reason:
status += style(f" ({reason})", fg="bright_black")
status += style(". Press Enter to disturb.", fg="bright_black")
input_prompt = style("Reason (optional): ", fg="yellow")
rendered = f"{status}\n{input_prompt}"
if not sys.stdout.isatty():
return _ANSI_ESCAPE_RE.sub("", rendered)
return rendered
@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 line prompt: pressing Enter (optionally after
typing a reason) "disturbs" the wait so the turn continues immediately.
Non-interactive runs simply sleep the full duration.
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.
"""
if duration < 0:
return "error: duration must not be negative"
@@ -24,12 +48,7 @@ async def wait(duration: int, *, reason: str | None = None) -> str:
if not backend.is_interactive():
await asyncio.sleep(duration)
return f"waited {duration}s"
prompt = WAIT_PROMPT.format(duration=duration)
if reason:
prompt = (
f"Waiting for {duration}s (reason: {reason}). Press Enter to disturb. "
"Feel free to ship any reason before pressing Enter. "
)
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"
+29 -1
View File
@@ -14,7 +14,7 @@ if TYPE_CHECKING:
from pathlib import Path
from plyngent.agent.todo_stack import TodoStack
from plyngent.tools.workspace import WorkspacePolicy
from plyngent.tools.workspace import AccessMode, WorkspacePolicy
def _default_instance_data() -> PersistentDataView[Any]:
@@ -74,10 +74,15 @@ class SessionState:
todo: TodoStack | None = None
# Host hook after todo tools mutate (e.g. CLI memory persist).
on_todo_change: Callable[[], None] | None = None
# Host hook after directory-access grants change (e.g. CLI memory persist).
on_access_change: Callable[[], None] | None = None
# Soft-confirm grants live map: tool_name → True (Phase 1 key is tool name).
# Durable copy lives under data["grants"] (see plyngent.tools.grants).
grants: dict[str, bool] = field(default_factory=dict)
extras: dict[str, Any] = field(default_factory=dict)
# 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)
def has_grant(self, tool_name: str) -> bool:
return bool(self.grants.get(tool_name))
@@ -137,6 +142,29 @@ def bind_session(state: SessionState | None) -> Generator[SessionState | None]:
_session.reset(token)
_read_only: ContextVar[bool] = ContextVar("plyngent_read_only_context", default=False)
@contextmanager
def read_only_context() -> Generator[None]:
"""Mark the current context as read-only for tool execution.
Tools that may mutate depending on arguments (e.g. ``fetch`` with a
non-GET method) check :func:`is_read_only_context` and refuse to mutate
while it is set.
"""
token = _read_only.set(True)
try:
yield
finally:
_read_only.reset(token)
def is_read_only_context() -> bool:
"""Whether tools currently run inside a read-only context."""
return _read_only.get()
@contextmanager
def bind_tool_context(
*,
+3 -3
View File
@@ -4,7 +4,7 @@ import json
import shlex
from typing import TYPE_CHECKING, cast
from plyngent.tools.workspace import WorkspaceError, resolve_path
from plyngent.tools.workspace import AccessMode, WorkspaceError, resolve_path
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
@@ -140,7 +140,7 @@ def _write_file_reason(args: Mapping[str, object]) -> str | None:
if not isinstance(path, str) or not path:
return None
try:
target = resolve_path(path)
target = resolve_path(path, required=AccessMode.WRITE)
except WorkspaceError:
# Path policy will fail later; no soft-confirm without a resolved target.
return None
@@ -160,7 +160,7 @@ def _copy_path_reason(args: Mapping[str, object]) -> str | None:
if not isinstance(dst, str) or not dst:
return f"copy {src!r} → {dst!r} (overwrite)"
try:
target = resolve_path(dst)
target = resolve_path(dst, required=AccessMode.WRITE)
except WorkspaceError:
return None
if target.exists():
+42 -10
View File
@@ -2,8 +2,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.agent import ToolTag, invalidate_lineno_read, lineno_read_lines, tool
from plyngent.tools.workspace import resolve_path
from plyngent.agent import ToolTag, lineno_read_lines, lineno_read_mtime, tool
from plyngent.tools.workspace import AccessMode, resolve_path
if TYPE_CHECKING:
from pathlib import Path
@@ -60,10 +60,11 @@ def _unread_lines_in_range(
"""Lines in ``start_line..end_line`` that were not read with line numbers.
Appending (``start_line == n + 1``) requires the last line to be read, so
line numbers stay truthful; the append point itself is not a line.
line numbers stay truthful; the append point itself is not a line. An empty
file (``n == 0``) has nothing to clobber, so appending needs no read.
"""
if start_line == n + 1:
return [] if n in read_lines else [n]
return [] if n == 0 or n in read_lines else [n]
return sorted(set(range(start_line, end_line + 1)) - read_lines)
@@ -77,6 +78,14 @@ def _append_after(target: Path, path: str, text: str, lines: list[str], new_cont
return f"appended content after line {n} in {path}"
def _excerpt(text: str, limit: int = 40) -> str:
"""Collapse newlines and truncate for a one-line message excerpt."""
one = text.replace("\r\n", " ").replace("\n", " ")
if len(one) > limit:
return one[:limit] + "…"
return one
def _replace_range(
target: Path,
path: str,
@@ -92,7 +101,11 @@ def _replace_range(
new_lines = lines[: start_line - 1] + replacement + lines[end:]
_ = target.write_text("".join(new_lines), encoding="utf-8")
removed = end - start_line + 1
return f"replaced lines {start_line}-{end} ({removed} lines) with {len(replacement)} lines in {path}"
old_first = _excerpt(lines[start_line - 1].rstrip("\r\n"))
return (
f"replaced lines {start_line}-{end} ({removed} lines; first: {old_first!r}) "
f"with {len(replacement)} lines in {path}"
)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
@@ -101,7 +114,7 @@ async def edit_lineno(path: str, start_line: int, end_line: int, new_content: st
``new_content`` may be multi-line. Use an empty string to delete the range.
"""
target = resolve_path(path)
target = resolve_path(path, required=AccessMode.WRITE)
if not target.is_file():
return f"error: not a file: {path}"
@@ -111,18 +124,37 @@ async def edit_lineno(path: str, start_line: int, end_line: int, new_content: st
if err is not None:
return err
unread = _unread_lines_in_range(lineno_read_lines(str(target)), start_line, end_line, len(lines))
read_lines = lineno_read_lines(str(target))
unread = _unread_lines_in_range(read_lines, start_line, end_line, len(lines))
if unread:
preview = unread[:_UNREAD_PREVIEW]
head = ", ".join(str(i) for i in preview)
if len(unread) > _UNREAD_PREVIEW:
head += f", … ({len(unread)} total)"
return f"error: lines {head} not read; call read_file(path, with_lineno=true) first to see current line numbers"
window = ""
if read_lines:
window = f" (this turn read lines {min(read_lines)}-{max(read_lines)})"
return (
f"error: lines {head} not read{window}; call read_file(path, with_lineno=true) "
"first — read with a small margin around the lines you plan to edit"
)
recorded_mtime = lineno_read_mtime(str(target))
if recorded_mtime is not None:
try:
current_mtime = target.stat().st_mtime_ns
except OSError:
current_mtime = None
if current_mtime is not None and current_mtime != recorded_mtime:
return (
f"error: {path} changed since it was read (line numbers may be stale); "
"re-read with read_file(path, with_lineno=true)"
)
if start_line == len(lines) + 1:
result = _append_after(target, path, text, lines, new_content)
else:
result = _replace_range(target, path, lines, start_line, end_line, new_content)
# Line numbers are stale after a write; force a re-read before next edit.
invalidate_lineno_read(str(target))
# Line numbers are stale after a write; the fresh mtime makes the next
# edit_lineno reject until the file is re-read.
return result
+32 -10
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from plyngent.agent import ToolTag, invalidate_lineno_read, tool
from plyngent.tools.workspace import resolve_path
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import AccessMode, resolve_path
def _count_non_overlapping(text: str, needle: str) -> int:
@@ -18,15 +18,31 @@ def _count_non_overlapping(text: str, needle: str) -> int:
start = index + len(needle)
def _success_message(path: str, *, replaced: int, found: int) -> str:
def _excerpt(text: str, limit: int = 40) -> str:
"""Collapse newlines and truncate for a one-line message excerpt."""
one = text.replace("\r\n", " ").replace("\n", " ")
if len(one) > limit:
return one[:limit] + "…"
return one
def _success_message(
path: str,
*,
replaced: int,
found: int,
old_string: str,
new_string: str,
) -> str:
remaining = found - replaced
unit = "occurrence" if replaced == 1 else "occurrences"
change = f" ('{_excerpt(old_string)}' → '{_excerpt(new_string)}')"
if remaining == 0:
if found == 1:
return f"replaced 1 occurrence in {path}"
return f"replaced {replaced} {unit} in {path} (all {found} matches)"
return f"replaced 1 occurrence in {path}{change}"
return f"replaced {replaced} {unit} in {path}{change} (all {found} matches)"
return (
f"replaced {replaced} {unit} in {path} "
f"replaced {replaced} {unit} in {path}{change} "
f"({replaced} of {found} matches; {remaining} remain — "
f"raise max_replaces or use a more specific old_string)"
)
@@ -44,7 +60,7 @@ async def edit_replace(path: str, old_string: str, new_string: str, max_replaces
return "error: old_string must not be empty"
if max_replaces < 1:
return "error: max_replaces must be >= 1"
target = resolve_path(path)
target = resolve_path(path, required=AccessMode.WRITE)
if not target.is_file():
return f"error: not a file: {path}"
text = target.read_text(encoding="utf-8", errors="replace")
@@ -54,6 +70,12 @@ async def edit_replace(path: str, old_string: str, new_string: str, max_replaces
n = min(max_replaces, found)
updated = text.replace(old_string, new_string, n)
_ = target.write_text(updated, encoding="utf-8")
# Any lineno read state is stale after a write; force a re-read.
invalidate_lineno_read(str(target))
return _success_message(path, replaced=n, found=found)
# The write changes the file's mtime, so edit_lineno's freshness check
# rejects stale line numbers until the model re-reads with_lineno.
return _success_message(
path,
replaced=n,
found=found,
old_string=old_string,
new_string=new_string,
)
+23 -10
View File
@@ -4,7 +4,7 @@ import shutil
from pathlib import Path
from plyngent.agent import ToolTag, invalidate_lineno_read, tool
from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_workspace_root, resolve_path
def _kind(path: Path) -> str:
@@ -26,18 +26,28 @@ def _remove_existing(path: Path) -> None:
def _resolve_pair(src: str, dst: str) -> tuple[Path, Path, Path] | str:
try:
source = resolve_path(src)
dest = resolve_path(dst)
source = resolve_path(src, required=AccessMode.READ)
dest = resolve_path(dst, required=AccessMode.WRITE)
root = get_workspace_root()
except WorkspaceError as exc:
return f"error: {exc}"
return source, dest, root
def _prepare_dest(source: Path, dest: Path, root: Path, *, overwrite: bool, dst_label: str) -> Path | str:
def _display(path: Path, root: Path) -> str:
"""Workspace-relative display path, falling back to absolute outside the root."""
try:
return str(path.relative_to(root))
except ValueError:
return str(path)
def _prepare_dest(source: Path, dest: Path, *, overwrite: bool, dst_label: str) -> Path | str:
"""Resolve copy/move destination; return error string on conflict."""
if source.is_file() and dest.is_dir():
dest = resolve_path(str((dest / source.name).relative_to(root)))
# ``dest`` may be outside the primary root (temp workspace / grant), so
# resolve the child directly instead of relativizing against the root.
dest = resolve_path(str(dest / source.name), required=AccessMode.WRITE)
if dest.exists() or dest.is_symlink():
if not overwrite:
return f"error: destination exists: {dst_label} (set overwrite=true)"
@@ -56,7 +66,7 @@ def _copy_or_move_validated(
overwrite: bool,
action: str,
) -> str:
prepared = _prepare_dest(source, dest, root, overwrite=overwrite, dst_label=dst)
prepared = _prepare_dest(source, dest, overwrite=overwrite, dst_label=dst)
if isinstance(prepared, str):
return prepared
dest = prepared
@@ -68,13 +78,16 @@ def _copy_or_move_validated(
else:
_ = shutil.copy2(source, dest, follow_symlinks=False)
label = _kind(source)
# A copy does not change the source, but the destination content is new.
# A copy does not change the source, but the destination content is
# new. copy2/copytree preserve the source mtime, so the freshness
# check alone could miss the change — invalidate explicitly.
invalidate_lineno_read(str(dest))
return f"copied {label} {src} -> {dest.relative_to(root)}"
return f"copied {label} {src} -> {_display(dest, root)}"
moved = Path(shutil.move(str(source), str(dest))).resolve()
# shutil.move preserves mtime too; drop read state for both paths.
invalidate_lineno_read(str(source))
invalidate_lineno_read(str(moved))
return f"moved {_kind(moved)} {src} -> {moved.relative_to(root)}"
return f"moved {_kind(moved)} {src} -> {_display(moved, root)}"
except WorkspaceError as exc:
return f"error: {exc}"
except OSError as exc:
@@ -144,7 +157,7 @@ async def delete_path(path: str, *, recursive: bool = False) -> str:
``recursive=true`` (uses ``shutil.rmtree``). The workspace root cannot be deleted.
"""
try:
target = resolve_path(path)
target = resolve_path(path, required=AccessMode.WRITE)
root = get_workspace_root()
except WorkspaceError as exc:
return f"error: {exc}"
+51 -21
View File
@@ -6,26 +6,68 @@ from typing import cast
from plyngent.agent import ToolTag, tool
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
from plyngent.tools.file.read import line_range_label, read_raw_text
from plyngent.tools.file.read import line_range_label, read_file, read_raw_text
from plyngent.tools.net.fetch import fetch
from plyngent.tools.truncate_token import (
TruncateToken,
decode_truncate_token,
get_remainder,
truncate_with_token,
)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
def _memory_chunk(parsed: TruncateToken, max_chars: int) -> str:
"""Serve one chunk from the in-memory remainder store (or an error)."""
remainder = get_remainder(parsed.location)
if remainder is None:
return "error: truncate token expired (no longer in memory); re-run the tool"
start = parsed.offset
segment = remainder[start : start + max_chars]
chunk, _ = truncate_with_token(
segment,
max_chars,
kind="memory",
location=parsed.location,
offset=start,
limit=max_chars,
total_len=len(remainder),
)
return chunk
async def _numbered_continuation(text: str, location: str, char_offset: int, max_chars: int) -> str:
"""Resume a numbered read at the line containing *char_offset*.
Delegates to ``read_file(with_lineno=True)`` so the continuation carries
``N|`` line numbers and marks the resumed lines readable for ``edit_lineno``.
"""
pos = 0
line = 0
for ln in text.splitlines(keepends=True):
if pos + len(ln) > char_offset:
break
pos += len(ln)
line += 1
return await read_file.handler(
location,
offset=line,
with_lineno=True,
max_chars=max_chars,
)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def get_truncated(token: str, *, max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS) -> str:
"""Fetch the next chunk of a truncated result using its ``truncate_token``.
Pass the token shown at the end of a truncated ``read_file``, ``fetch``,
``run_command``, or earlier ``get_truncated`` result to keep reading without
re-requesting the whole source or raising limits. Truncated output carries a
fresh token, so chunks chain indefinitely. File chunks start with a 1-based
``L{begin}-{end}`` line range like ``read_file``; memory chunks (generic
tool output) are served from a short-lived in-memory store that is forgotten
when the agent exits.
fresh token, so chunks chain indefinitely. File chunks resume in the same
view as the original read: numbered reads continue as numbered lines
(editable via ``edit_lineno``), plain reads as raw text with a 1-based
``L{begin}-{end}`` range. Memory chunks (generic tool output) are served
from a short-lived in-memory store that is forgotten when the agent exits.
"""
parsed = decode_truncate_token(token)
if parsed is None:
@@ -33,26 +75,14 @@ async def get_truncated(token: str, *, max_chars: int = DEFAULT_TOOL_RESULT_MAX_
if parsed.kind == "http":
return await fetch.handler(parsed.location, offset=parsed.offset, max_chars=parsed.limit)
if parsed.kind == "memory":
remainder = get_remainder(parsed.location)
if remainder is None:
return "error: truncate token expired (no longer in memory); re-run the tool"
start = parsed.offset
segment = remainder[start : start + max_chars]
chunk, _ = truncate_with_token(
segment,
max_chars,
kind="memory",
location=parsed.location,
offset=start,
limit=max_chars,
total_len=len(remainder),
)
return chunk
return _memory_chunk(parsed, max_chars)
text, err = read_raw_text(parsed.location)
if err:
return err
text = cast("str", text)
start = parsed.offset
if parsed.numbered:
return await _numbered_continuation(text, parsed.location, start, max_chars)
segment = text[start : start + max_chars]
chunk, _ = truncate_with_token(
segment,
+3 -3
View File
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
from plyngent.agent import ToolTag, tool
from plyngent.tools.file.tree import VCS_DIR_NAMES
from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_workspace_root, resolve_path
if TYPE_CHECKING:
from pathlib import Path
@@ -57,7 +57,7 @@ def _collect_glob(
def _resolve_glob_base(path: str) -> tuple[Path, Path] | str:
try:
root = get_workspace_root()
base = resolve_path(path)
base = resolve_path(path, required=AccessMode.READ)
except WorkspaceError as exc:
return f"error: {exc}"
if not base.is_dir():
@@ -65,7 +65,7 @@ def _resolve_glob_base(path: str) -> tuple[Path, Path] | str:
return root, base
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def glob_paths(
pattern: str,
path: str = ".",
+3 -3
View File
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
from plyngent.agent import ToolTag, tool
from plyngent.tools.file.tree import VCS_DIR_NAMES
from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_workspace_root, resolve_path
if TYPE_CHECKING:
from collections.abc import Iterator
@@ -65,7 +65,7 @@ def _iter_files(base: Path, *, skip_hidden_dirs: bool) -> list[Path]:
def _resolve_files(path: str, *, skip_hidden_dirs: bool) -> list[Path] | str:
try:
base = resolve_path(path)
base = resolve_path(path, required=AccessMode.READ)
except WorkspaceError as exc:
return f"error: {exc}"
if not base.exists():
@@ -127,7 +127,7 @@ def _compile_pattern(pattern: str, *, case_insensitive: bool) -> re.Pattern[str]
return f"error: invalid regex: {exc}"
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def grep_files(
pattern: str,
path: str = ".",
+3 -3
View File
@@ -1,13 +1,13 @@
from __future__ import annotations
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path
from plyngent.tools.workspace import AccessMode, resolve_path
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def listdir(path: str = ".") -> str:
"""List entries in a directory under the workspace (name and type)."""
target = resolve_path(path)
target = resolve_path(path, required=AccessMode.READ)
if not target.is_dir():
return f"error: not a directory: {path}"
lines: list[str] = []
+40 -13
View File
@@ -4,8 +4,8 @@ from typing import cast
from plyngent.agent import ToolTag, mark_lineno_read, tool
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
from plyngent.tools.truncate_token import truncate_with_token
from plyngent.tools.workspace import resolve_path
from plyngent.tools.truncate_token import TruncateToken, truncate_with_token, truncation_marker
from plyngent.tools.workspace import AccessMode, resolve_path
_LINENO_WIDTH = 6
@@ -41,7 +41,7 @@ def read_raw_text(path: str) -> tuple[str | None, str]:
always refer to the raw file text (never the ``L{begin}-{end}`` header).
Missing paths and directories are distinguished for the caller.
"""
target = resolve_path(path)
target = resolve_path(path, required=AccessMode.READ)
if not target.exists():
return None, f"error: file not found: {path}"
if not target.is_file():
@@ -49,7 +49,7 @@ def read_raw_text(path: str) -> tuple[str | None, str]:
return target.read_text(encoding="utf-8", errors="replace"), ""
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def read_file(
path: str,
*,
@@ -64,13 +64,15 @@ async def read_file(
(None = rest of file). When ``with_lineno`` is true, each line is prefixed
with its 1-based file line number (`` N|…``), matching ``edit_lineno``
numbering, and those lines are marked readable for ``edit_lineno`` this turn.
Only the displayed lines become editable, so read with a small margin around
any range you plan to edit with ``edit_lineno``.
``max_chars`` caps the returned slice (default: the agent's tool-result cap,
``DEFAULT_TOOL_RESULT_MAX_CHARS``); a ``truncate_token`` is appended when cut
so ``get_truncated`` can continue reading the rest without a new request.
Pass ``max_chars < 1`` to read unbounded.
"""
target = resolve_path(path)
target = resolve_path(path, required=AccessMode.READ)
text, err = read_raw_text(path)
if err:
return err
@@ -83,14 +85,10 @@ async def read_file(
if start >= len(lines):
return ""
slice_lines = lines[start:end]
if with_lineno:
mark_lineno_read(str(target), set(range(start + 1, end + 1)))
body = _format_with_lineno(slice_lines, start_lineno=start + 1)
else:
body = "".join(slice_lines)
body = _format_with_lineno(slice_lines, start_lineno=start + 1) if with_lineno else "".join(slice_lines)
char_start = len("".join(lines[:start]))
if max_chars >= 1:
char_start = len("".join(lines[:start]))
body, _ = truncate_with_token(
body, token = truncate_with_token(
body,
max_chars,
kind="file",
@@ -102,10 +100,39 @@ async def read_file(
# returned slice itself was cut by max_chars (not just "file continues").
emit_more_after=False,
)
else:
token = None
if not body:
return ""
if with_lineno:
return body # per-line numbers already show the range
# Mark only the lines actually displayed: complete numbered lines in the
# returned body (the [Truncated] marker, when present, is not a file line).
# The max_chars-truncated tail was never shown, so it stays unread —
# edit_lineno must not trust line numbers the model never saw.
counted = body
if token is not None:
counted = body.split("\n[Truncated", 1)[0]
shown = counted.count("\n")
# The truncate cursor counts *numbered* chars (each line carries an
# "N|" prefix), which would over-advance into raw text and skip
# lines on continuation. Rebase the token onto raw characters of the
# complete lines shown (at least one, so the cursor always moves)
# and mark it as the numbered view for get_truncated.
consumed_lines = max(shown, 1)
raw_consumed = sum(len(ln) for ln in slice_lines[:consumed_lines])
corrected = TruncateToken(
kind="file",
location=path,
offset=char_start + raw_consumed,
limit=max_chars,
numbered=True,
)
omitted = len(text) - (char_start + raw_consumed)
body = counted + truncation_marker(max_chars, omitted, corrected)
else:
shown = counted.count("\n")
mark_lineno_read(str(target), set(range(start + 1, start + 1 + shown)))
return body
begin = start + 1
end_line = start + len(slice_lines)
return f"L{begin}-{end_line}\n{body}"
+104 -8
View File
@@ -1,15 +1,20 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError, get_path_denylist, resolve_path
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_path_denylist, resolve_path
if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path
# Model-facing renderings. ``markdown`` (default) is the cheapest, most
# reliable representation for the agent; ``flat`` gives directly-usable paths;
# ``decorated`` keeps the classic box-drawing tree for human readers.
type TreeFormat = Literal["markdown", "flat", "decorated"]
DEFAULT_MAX_DEPTH = 4
DEFAULT_MAX_ENTRIES = 50
@@ -144,6 +149,80 @@ def _render_tree(
lines.append(f"{prefix}└── … ({more} more entries not shown)")
def _render_tree_markdown(
directory: Path,
*,
prefix: str,
depth: int,
limits: _TreeLimits,
lines: list[str],
) -> None:
"""Nested ``- `` bullet list (2-space indent per level)."""
if depth >= limits.max_depth:
return
children = _list_children(directory, limits=limits)
if isinstance(children, str):
lines.append(f"{prefix}- {children}")
return
truncated = len(children) > limits.max_entries
shown = children[: limits.max_entries]
for child in shown:
try:
is_dir = child.is_dir()
except OSError:
lines.append(f"{prefix}- {child.name} [error: stat failed]")
continue
if is_dir:
lines.append(f"{prefix}- {child.name}/")
_render_tree_markdown(
child,
prefix=prefix + " ",
depth=depth + 1,
limits=limits,
lines=lines,
)
else:
lines.append(f"{prefix}- {child.name}")
if truncated:
more = len(children) - limits.max_entries
lines.append(f"{prefix}- … ({more} more entries not shown)")
def _flat_paths(directory: Path, *, limits: _TreeLimits) -> list[str]:
"""All paths (dirs with trailing ``/``, files plain) in depth-first order.
Includes empty directories; bounded by *max_depth* (``max_entries`` is
applied to the total by the caller).
"""
out: list[str] = []
def visit(child_dir: Path, rel: str, depth: int) -> None:
if depth >= limits.max_depth:
return
children = _list_children(child_dir, limits=limits)
if isinstance(children, str):
out.append(f"{rel or child_dir.name} [error: cannot list]")
return
for child in children:
try:
is_dir = child.is_dir()
except OSError:
out.append(f"{child.name} [error: stat failed]")
continue
child_rel = child.name if not rel else f"{rel}/{child.name}"
if is_dir:
out.append(f"{child_rel}/")
visit(child, child_rel, depth + 1)
else:
out.append(child_rel)
visit(directory, "", 0)
return out
def _resolve_skip_basenames(skip_dirs: Sequence[str] | None) -> frozenset[str]:
"""None → default noise set; explicit list (including empty) replaces defaults."""
if skip_dirs is None:
@@ -151,10 +230,11 @@ def _resolve_skip_basenames(skip_dirs: Sequence[str] | None) -> frozenset[str]:
return frozenset(name for name in skip_dirs if name)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def tree(
path: str = ".",
*,
format: TreeFormat = "markdown", # noqa: A002 — model-facing param name
max_depth: int = DEFAULT_MAX_DEPTH,
max_entries: int = DEFAULT_MAX_ENTRIES,
skip_hidden_dirs: bool = True,
@@ -174,8 +254,15 @@ async def tree(
``apply_path_denylist`` (default true) hides entries whose full path matches
the agent ``path_denylist`` policy.
``format`` selects the representation (the model chooses per call):
``markdown`` (default) renders a nested ``- `` bullet list — cheapest and
most reliable for the agent; ``flat`` lists one relative path per line
(directories with a trailing ``/``) so every path is directly usable
without reconstructing it; ``decorated`` uses the classic box-drawing tree.
``max_depth`` limits how deep directories are expanded (1 = origin + children).
``max_entries`` caps how many entries are listed per directory.
``max_entries`` caps entries per directory (``markdown``/``decorated``) or
the total number of lines (``flat``).
"""
if max_depth < 1:
return "error: max_depth must be >= 1"
@@ -183,14 +270,12 @@ async def tree(
return "error: max_entries must be >= 1"
try:
origin = resolve_path(path)
origin = resolve_path(path, required=AccessMode.READ)
except WorkspaceError as exc:
return f"error: {exc}"
if not origin.is_dir():
return f"error: not a directory: {path}"
root_label = path.rstrip("/\\") or "."
lines = [f"{root_label}/"]
limits = _TreeLimits(
max_depth=max_depth,
max_entries=max_entries,
@@ -198,7 +283,18 @@ async def tree(
skip_basenames=_resolve_skip_basenames(skip_dirs),
apply_path_denylist=apply_path_denylist,
)
_render_tree(
if format == "flat":
paths = _flat_paths(origin, limits=limits)
lines = paths[:max_entries]
if len(paths) > max_entries:
lines.append(f"… ({len(paths) - max_entries} more paths not shown)")
return "\n".join(lines)
root_label = path.rstrip("/\\") or "."
lines: list[str] = [] if format == "markdown" else [f"{root_label}/"]
renderer = _render_tree_markdown if format == "markdown" else _render_tree
renderer(
origin,
prefix="",
depth=0,
+5 -4
View File
@@ -1,14 +1,15 @@
from __future__ import annotations
from plyngent.agent import ToolTag, invalidate_lineno_read, tool
from plyngent.tools.workspace import resolve_path
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import AccessMode, resolve_path
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def write_file(path: str, content: str) -> str:
"""Write text content to a file under the workspace (creates parents)."""
target = resolve_path(path)
target = resolve_path(path, required=AccessMode.WRITE)
target.parent.mkdir(parents=True, exist_ok=True)
_ = target.write_text(content, encoding="utf-8")
invalidate_lineno_read(str(target))
# The write changes the file's mtime, so edit_lineno's freshness check
# rejects stale line numbers until the model re-reads with_lineno.
return f"wrote {len(content)} characters to {path}"
+108
View File
@@ -0,0 +1,108 @@
"""Register MCP server tools into the tool catalog (namespaced, LOCAL tags).
Mirrors :mod:`plyngent.tools.plugins`: the catalog only *registers* what the
model may see; hosts still select into a :class:`ToolRegistry`. Connections
must already be started (see :class:`~plyngent.runtime.mcp_client.McpManager`);
this module is sync-only so registry rebuilds never need an event loop.
Tool names are namespaced ``mcp__<server>__<tool>`` so two servers (or a
server and a builtin) can never collide on sanitized names; the catalog still
refuses true clashes.
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from plyngent.agent.tools import ToolDefinition, ToolTag
from plyngent.tools.catalog import ToolSource, get_catalog
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from plyngent.config.models import McpConfig
from plyngent.runtime.mcp_client import McpManager, McpTool
_MCP_PREFIX = "mcp__"
_NAME_SEGMENT_MAX = 64
_SANITIZE_PATTERN = re.compile(r"[^A-Za-z0-9_-]")
def _sanitize_segment(part: str) -> str:
"""Reduce a server/tool name to a model-safe, bounded name segment."""
return _SANITIZE_PATTERN.sub("_", part)[:_NAME_SEGMENT_MAX] or "srv"
def mcp_tool_name(server: str, tool: str) -> str:
"""Namespaced catalog name for one MCP tool (collision-free per server)."""
return f"{_MCP_PREFIX}{_sanitize_segment(server)}__{_sanitize_segment(tool)}"
def _make_handler(
manager: McpManager,
server: str,
tool: str,
) -> Callable[..., Awaitable[str]]:
async def handler(**arguments: object) -> str:
return await manager.call(server, tool, dict(arguments))
return handler
def register_mcp_server_tools(
server: str,
tools: list[McpTool],
*,
manager: McpManager,
read_only: bool = False,
) -> list[str]:
"""Register one server's advertised tools; returns the registered names.
Tags: LOCAL (plus READ_ONLY when the server config opts in) — never YOLO or
TRUSTABLE, so host-side danger classification always sees every call.
"""
catalog = get_catalog()
source = ToolSource(kind="mcp", plugin_id=server)
names: list[str] = []
for entry in tools:
if not entry.name:
continue
tags = ToolTag.LOCAL
if read_only:
tags |= ToolTag.READ_ONLY
description = entry.description or f"MCP tool {entry.name!r} from server {server!r}"
definition = ToolDefinition(
name=mcp_tool_name(server, entry.name),
description=f"[mcp:{server}] {description}",
parameters=dict(entry.input_schema),
handler=_make_handler(manager, server, entry.name),
tags=tags,
)
catalog.register(definition, source=source)
names.append(definition.name)
return names
def purge_mcp_tools() -> int:
"""Remove previously registered MCP tools (call before re-registering)."""
return get_catalog().purge_kind("mcp")
def register_mcp_tools(manager: McpManager, config: McpConfig) -> list[str]:
"""Register every connected server's tools; replaces prior MCP entries."""
_ = purge_mcp_tools()
names: list[str] = []
for connection in manager.connections():
if not connection.connected or not connection.tools:
continue
server_cfg = config.servers.get(connection.name)
names.extend(
register_mcp_server_tools(
connection.name,
connection.tools,
manager=manager,
read_only=server_cfg.read_only if server_cfg is not None else False,
)
)
return names
+6 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from plyngent.agent import ToolTag, tool
from plyngent.tools.context import is_read_only_context
from plyngent.tools.net.client import http_fetch
from plyngent.tools.net.policy import (
DEFAULT_MAX_BODY_CHARS_IN,
@@ -66,7 +67,7 @@ def format_fetch_result(
return "\n".join(parts)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO | ToolTag.TRUSTABLE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO | ToolTag.TRUSTABLE | ToolTag.READ_ONLY)
async def fetch(
url: str,
*,
@@ -92,9 +93,13 @@ async def fetch(
Private/loopback/link-local hosts require an explicit human policy grant
(not skipped by YOLO). HTTPS→HTTP redirects are blocked unless
``allow_http_downgrade`` is true.
Read-only contexts (e.g. ``/btw --tools=read``) accept only ``GET``.
"""
try:
verb = normalize_method(method)
if is_read_only_context() and verb != "GET":
return "error: fetch method must be GET in a read-only context (POST/PUT/DELETE mutate)"
parsed = parse_fetch_url(url)
hdrs = normalize_request_headers(headers, user_agent=user_agent)
body_bytes: bytes | None = None
+2 -1
View File
@@ -10,6 +10,7 @@ if TYPE_CHECKING:
from pathlib import Path
from plyngent.tools.workspace import (
AccessMode,
WorkspaceError,
check_command_allowed,
get_workspace_root,
@@ -61,7 +62,7 @@ def format_command_result(
def resolve_workdir(cwd: str) -> tuple["Path", str]:
"""Return (absolute workdir, display path relative to workspace when possible)."""
workdir = resolve_path(cwd)
workdir = resolve_path(cwd, required=AccessMode.EXEC)
if not workdir.is_dir():
msg = f"not a directory: {cwd}"
raise WorkspaceError(msg)
+2 -2
View File
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, ClassVar, cast
from plyngent.tools.process.pty_backend import PtyHandle, pty_available, spawn_pty
from plyngent.tools.process.pty_terminal import sanitize_pty_output_for_tool
from plyngent.tools.workspace import WorkspaceError, check_command_allowed, resolve_path
from plyngent.tools.workspace import AccessMode, WorkspaceError, check_command_allowed, resolve_path
if TYPE_CHECKING:
from collections.abc import Callable
@@ -122,7 +122,7 @@ class PtyManager:
msg = "PTY is not available on this platform (Windows needs pywinpty)"
raise WorkspaceError(msg)
check_command_allowed(command)
workdir = resolve_path(cwd)
workdir = resolve_path(cwd, required=AccessMode.EXEC)
if not workdir.is_dir():
msg = f"cwd is not a directory: {cwd}"
raise WorkspaceError(msg)
+1 -1
View File
@@ -48,7 +48,7 @@ async def _with_todo_stack(mutator: Callable[[TodoStack], str]) -> str:
return result
@tool(name="todo_list", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
@tool(name="todo_list", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE | ToolTag.READ_ONLY)
async def todo_list() -> str:
"""Show the LIFO stack of **task groups** (TOP group = current breakdown level).
+3 -1
View File
@@ -37,13 +37,15 @@ class TruncateToken(msgspec.Struct, frozen=True):
lines (files is line-based via ``read_file``); ``get_truncated`` interprets
by ``kind``. For ``memory``, ``location`` is a key into the in-memory
remainder store and ``offset``/``limit`` index characters of that stored
remainder.
remainder. ``numbered`` marks file tokens from ``read_file(with_lineno)``:
the continuation resumes the numbered view instead of raw text.
"""
kind: TruncateKind
location: str
offset: int
limit: int
numbered: bool = False
# In-memory remainders for generic (non-resumable) tool output. Process-lifetime
+5 -5
View File
@@ -22,7 +22,7 @@ def _backend_or_error() -> VcsBackend | str:
return backend
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def vcs_kind() -> str:
"""Return the detected VCS kind under the workspace (e.g. ``git``), or an error."""
backend = _backend_or_error()
@@ -31,7 +31,7 @@ async def vcs_kind() -> str:
return backend.kind
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def vcs_status() -> str:
"""Show working-tree status for the detected VCS (read-only)."""
backend = _backend_or_error()
@@ -40,7 +40,7 @@ async def vcs_status() -> str:
return backend.status()
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def vcs_diff(path: str = "", *, staged: bool = False) -> str:
"""Show a unified diff for the detected VCS (read-only).
@@ -54,7 +54,7 @@ async def vcs_diff(path: str = "", *, staged: bool = False) -> str:
return backend.diff(staged=staged, path=rel)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def vcs_log(limit: int = 10) -> str:
"""Show recent commits for the detected VCS (read-only)."""
if limit < 1:
@@ -65,7 +65,7 @@ async def vcs_log(limit: int = 10) -> str:
return backend.log(limit=limit)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def vcs_branch() -> str:
"""Show the current branch / named head for the detected VCS (read-only)."""
backend = _backend_or_error()
+99 -12
View File
@@ -1,13 +1,14 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import IntEnum
from pathlib import Path
from typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from collections.abc import Callable, Mapping, Sequence
from plyngent.tools.context import InstanceState
from plyngent.tools.context import InstanceState, SessionState
DEFAULT_COMMAND_DENYLIST: frozenset[str] = frozenset(
{
@@ -39,6 +40,36 @@ MAX_TEMPORARY_WORKSPACES = 16
# Timed human override for command denylist (independent of YOLO soft-confirm).
DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS = 30.0
class AccessMode(IntEnum):
"""Ordered access level for path grants outside the workspace root.
A grant satisfies a requirement when its value is ``>=`` the requirement
(``read`` < ``write`` < ``exec``); ``exec`` also implies file writes.
"""
READ = 1
WRITE = 2
EXEC = 3
_ACCESS_MODE_NAMES: dict[str, AccessMode] = {
"read": AccessMode.READ,
"write": AccessMode.WRITE,
"exec": AccessMode.EXEC,
}
def parse_access_mode(value: str) -> AccessMode | None:
"""Parse a case-insensitive mode token (``read`` / ``write`` / ``exec``)."""
return _ACCESS_MODE_NAMES.get(value.strip().lower())
def mode_covers(granted: AccessMode, required: AccessMode) -> bool:
"""Whether a grant at *granted* satisfies a tool that needs *required*."""
return granted >= required
# Hook: (basename, argv, timeout_seconds) -> True allow for this session basename.
type PolicyConfirmHook = Callable[[str, Sequence[str], float], bool]
@@ -63,6 +94,10 @@ class WorkspacePolicy:
policy_allowed_commands: set[str] = field(default_factory=set)
policy_confirm_hook: PolicyConfirmHook | None = None
policy_confirm_timeout_seconds: float = DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS
# Static TOML pre-allow (resolved path → mode); never prompts.
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)
def _bound_instance() -> InstanceState | None:
@@ -71,6 +106,12 @@ def _bound_instance() -> InstanceState | None:
return get_instance()
def _bound_session() -> SessionState | None:
from plyngent.tools.context import get_session
return get_session()
def require_bound_instance() -> InstanceState:
"""Return the bound instance or raise :class:`WorkspaceError`."""
instance = _bound_instance()
@@ -243,11 +284,55 @@ def _under_any_root(resolved: Path, instance: InstanceState, policy: WorkspacePo
return False
def resolve_path(path: str | Path) -> Path:
"""Resolve ``path`` under the workspace root or an allowlisted temp root.
def granted_access_mode(resolved: Path) -> AccessMode | None:
"""Highest config / session / process mode covering *resolved*, if any."""
policy = active_workspace_policy()
session = _bound_session()
buckets: list[Mapping[Path, AccessMode]] = [policy.config_allow, policy.yolo_allow]
if session is not None:
buckets.append(session.access_grants)
best: AccessMode | None = None
for grants in buckets:
for root, granted in grants.items():
if (resolved == root or resolved.is_relative_to(root)) and (best is None or granted > best):
best = granted
return best
def _path_grant_covers(resolved: Path, required: AccessMode) -> bool:
"""Whether a config / session / process grant covers *resolved*."""
granted = granted_access_mode(resolved)
return granted is not None and mode_covers(granted, required)
def within_workspace_roots(resolved: Path) -> bool:
"""Whether *resolved* is under the primary root(s) or a temp allowlist root."""
instance = require_bound_instance()
return _under_any_root(resolved, instance, instance.workspace)
def denylist_match(resolved: Path, *, directory: bool = False) -> str | None:
"""Return the path denylist pattern matching *resolved*, or ``None``.
*directory* also matches a trailing-separator pattern (``/secret/``) against
the directory path itself, not just its children.
"""
resolved_str = str(resolved).replace("\\", "/")
if directory and not resolved_str.endswith("/"):
resolved_str += "/"
for pattern in active_workspace_policy().path_denylist:
if pattern and pattern.replace("\\", "/") in resolved_str:
return pattern
return None
def resolve_path(path: str | Path, *, required: AccessMode = AccessMode.WRITE) -> Path:
"""Resolve ``path`` under the workspace root, an allowlist root, or a grant.
Relative paths resolve against the **primary** workspace root. Absolute
paths may also land under a temporary workspace allowlist entry.
paths may also land under a temporary workspace allowlist entry or a
directory-access grant; ``required`` is the access mode the caller needs
(default :attr:`AccessMode.WRITE`, the conservative choice).
"""
instance = require_bound_instance()
policy = instance.workspace
@@ -256,15 +341,17 @@ def resolve_path(path: str | Path) -> Path:
if not candidate.is_absolute():
candidate = root / candidate
resolved = candidate.expanduser().resolve()
if not _under_any_root(resolved, instance, policy):
msg = f"path escapes workspace root ({root}): {path}"
if not _under_any_root(resolved, instance, policy) and not _path_grant_covers(resolved, required):
msg = (
f"path escapes workspace root ({root}): {path}; "
"call request_directory_access to request access to a directory outside the workspace"
)
raise WorkspaceError(msg)
# Normalize separators so denylist entries like ``/secrets/`` match on Windows.
resolved_str = str(resolved).replace("\\", "/")
for pattern in policy.path_denylist:
if pattern and pattern.replace("\\", "/") in resolved_str:
msg = f"path denied by policy (matched {pattern!r}): {path}"
raise WorkspaceError(msg)
matched = denylist_match(resolved)
if matched is not None:
msg = f"path denied by policy (matched {matched!r}): {path}"
raise WorkspaceError(msg)
return resolved
+88
View File
@@ -209,3 +209,91 @@ async def test_run_aside_tools_clones_registry_with_fresh_session() -> None:
pass
assert hits == ["ok"]
assert "aside" not in main_session.extras
async def test_run_aside_read_only_clone_excludes_write_tools() -> None:
"""A read-only-cloned registry passed to run_aside exposes no write tools."""
hits: list[str] = []
@tool(tags=ToolTag.LOCAL | ToolTag.READ_ONLY, register=False)
async def peek() -> str:
hits.append("peek")
return "peeked"
@tool(tags=ToolTag.LOCAL, register=False)
async def write_thing() -> str:
hits.append("write")
return "wrote"
registry = ToolRegistry([peek, write_thing], auto_bind_state=True)
def make_client(first_tool: str | None) -> Any:
calls = 0
class _Client:
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
) -> ChatCompletionResponse: ...
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[True]
) -> AsyncIterator[ChatCompletionChunk]: ...
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
del stream, param
nonlocal calls
calls += 1
if calls == 1 and first_tool is not None:
from plyngent.lmproto.openai_compatible.model import (
AssistantFunctionTool,
AssistantFunctionToolCall,
)
message = AssistantChatMessage(
content="",
tool_calls=[
AssistantFunctionToolCall(
id="c1",
function=AssistantFunctionTool(name=first_tool, arguments="{}"),
)
],
)
finish = "tool_calls"
else:
message = AssistantChatMessage(content="done")
finish = "stop"
return ChatCompletionResponse(
id="1",
object="chat.completion",
created=0,
model="m",
choices=[
ChatCompletionChoice(
index=0,
message=message,
logprobs={},
finish_reason=finish,
)
],
system_fingerprint="",
usage={},
)
return _Client()
# Read-only tool is available to the side turn.
agent_peek = ChatAgent(make_client("peek"), model="m", tools=registry, stream=False)
async for _ in agent_peek.run_aside("go", tools=registry.clone(read_only_only=True)):
pass
assert hits == ["peek"]
# Write tool is not in the read-only clone: the call errors, never runs.
hits.clear()
agent_write = ChatAgent(make_client("write_thing"), model="m", tools=registry, stream=False)
async for _ in agent_write.run_aside("go", tools=registry.clone(read_only_only=True)):
pass
assert hits == []
+352 -4
View File
@@ -65,7 +65,7 @@ async def test_flush_markdown_on_source_change(
call = AssistantFunctionToolCall(
id="1",
function=AssistantFunctionTool(name="run_argv", arguments='{"argv": ["echo", "x"]}'),
function=AssistantFunctionTool(name="vcs_diff", arguments='{"path": ""}'),
)
await render_events(
_aiter(
@@ -103,6 +103,14 @@ async def test_pretty_read_file_done(capsys: pytest.CaptureFixture[str]) -> None
assert "[tool ok]" not in out
async def test_pretty_read_file_numbered_no_range(capsys: pytest.CaptureFixture[str]) -> None:
"""Numbered reads carry no L header, so the summary falls back to ``(done)``."""
content = " 1|one\n 2|two\n"
await render_events(_aiter([_read_call('{"path": "a.txt", "with_lineno": true}'), _result(content)]))
out = capsys.readouterr().out
assert "* Read 'a.txt' (done)" in out
async def test_pretty_read_file_statuses(capsys: pytest.CaptureFixture[str]) -> None:
cases = [
("error: file not found: a.txt", "(file not found)"),
@@ -116,6 +124,44 @@ async def test_pretty_read_file_statuses(capsys: pytest.CaptureFixture[str]) ->
assert f"* Read 'a.txt' {status}" in out, f"status {status} not rendered for {content!r}"
def _tree_call(args_json: str) -> ToolCallEvent:
return ToolCallEvent(
tool_call=AssistantFunctionToolCall(
id="1",
function=AssistantFunctionTool(name="tree", arguments=args_json),
)
)
async def test_pretty_tree_markdown(capsys: pytest.CaptureFixture[str]) -> None:
result = "- src/\n - main.py\n - nested/\n - deep.txt\n- a.txt\n"
await render_events(_aiter([_tree_call('{"path": "."}'), _result(result)]))
out = capsys.readouterr().out
assert "* Tree '.' (2 dirs, 3 files, depth 3)" in out
assert "[tool]" not in out
async def test_pretty_tree_flat_omits_depth(capsys: pytest.CaptureFixture[str]) -> None:
result = "src/\nsrc/main.py\na.txt\n… (5 more paths not shown)"
await render_events(_aiter([_tree_call('{"path": ".", "format": "flat"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Tree '.' (1 dirs, 2 files)" in out
assert "depth" not in out
async def test_pretty_tree_decorated(capsys: pytest.CaptureFixture[str]) -> None:
result = "src/\n├── main.py\n└── nested/\n └── deep.txt\n"
await render_events(_aiter([_tree_call('{"path": "src", "format": "decorated"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Tree 'src' (1 dirs, 2 files, depth 2)" in out
async def test_pretty_tree_error(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_tree_call('{"path": "nope"}'), _result("error: not a directory: nope")]))
out = capsys.readouterr().out
assert "* Tree 'nope' (error)" in out
async def test_pretty_todo_push(capsys: pytest.CaptureFixture[str]) -> None:
call = ToolCallEvent(
tool_call=AssistantFunctionToolCall(
@@ -140,12 +186,12 @@ async def test_non_pretty_tool_keeps_old_style(capsys: pytest.CaptureFixture[str
call = ToolCallEvent(
tool_call=AssistantFunctionToolCall(
id="3",
function=AssistantFunctionTool(name="run_argv", arguments='{"argv": ["ls"]}'),
function=AssistantFunctionTool(name="vcs_diff", arguments='{"path": ""}'),
)
)
await render_events(_aiter([call, _result("exit_code=0")]))
await render_events(_aiter([call, _result("diff --git a/x b/x")]))
out = capsys.readouterr().out
assert "[tool] run_argv(" in out
assert "[tool] vcs_diff(" in out
assert "[tool ok]" in out
@@ -210,3 +256,305 @@ def test_markdown_render_available_respects_plain_env(monkeypatch: pytest.Monkey
monkeypatch.setenv("PLYNGENT_PLAIN", "1")
assert markdown_render_available() is False
monkeypatch.delenv("PLYNGENT_PLAIN", raising=False)
async def test_pretty_verbose_shows_full_result(capsys: pytest.CaptureFixture[str]) -> None:
"""Verbose mode prints the full tool result even for pretty tools."""
await render_events(
_aiter([_read_call('{"path": "a.txt"}'), _result("L1-4\none\ntwo\n")]),
verbose=True,
)
out = capsys.readouterr().out
assert "[tool ok]" in out
assert "L1-4\none\ntwo" in out
assert "* Read" not in out
async def test_pretty_plain_output_has_no_ansi(capsys: pytest.CaptureFixture[str]) -> None:
"""Pretty summaries are plain text when stdout is not a TTY."""
await render_events(_aiter([_read_call('{"path": "a.txt"}'), _result("L1-4\none\n")]))
out = capsys.readouterr().out
assert "\x1b[" not in out
async def test_tool_result_preview_first_line_and_count(capsys: pytest.CaptureFixture[str]) -> None:
"""Non-verbose preview shows the first line plus a line-count tail."""
set_markdown_enabled(False)
content = "first line\nsecond\nthird"
await render_events(_aiter([ToolResultEvent(message=ToolChatMessage(content=content, tool_call_id="1"))]))
out = capsys.readouterr().out
assert "[tool ok] first line (…2 more lines)" in out
await render_events(_aiter([ToolResultEvent(message=ToolChatMessage(content="only line", tool_call_id="2"))]))
out = capsys.readouterr().out
assert "[tool ok] only line" in out
assert "more" not in out
await render_events(_aiter([ToolResultEvent(message=ToolChatMessage(content="", tool_call_id="3"))]))
out = capsys.readouterr().out
assert "[tool ok] (no output)" in out
set_markdown_enabled(True)
def _pretty_call(name: str, args_json: str) -> ToolCallEvent:
return ToolCallEvent(
tool_call=AssistantFunctionToolCall(
id="1",
function=AssistantFunctionTool(name=name, arguments=args_json),
)
)
async def test_pretty_listdir(capsys: pytest.CaptureFixture[str]) -> None:
result = "dir\tsrc\nfile\tREADME.md\nfile\tpyproject.toml\n"
await render_events(_aiter([_pretty_call("listdir", '{"path": "src"}'), _result(result)]))
out = capsys.readouterr().out
assert "* List 'src' (1 dirs, 2 files)" in out
assert "[tool]" not in out
assert "[tool ok]" not in out
async def test_pretty_listdir_empty(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_pretty_call("listdir", '{"path": "src"}'), _result("(empty)")]))
out = capsys.readouterr().out
assert "* List 'src' (empty)" in out
async def test_pretty_listdir_error(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_pretty_call("listdir", '{"path": "nope"}'), _result("error: not a directory: nope")]))
out = capsys.readouterr().out
assert "* List 'nope' (error: not a directory: nope)" in out
async def test_pretty_glob(capsys: pytest.CaptureFixture[str]) -> None:
result = "src/a.py\nsrc/b.py\n"
await render_events(_aiter([_pretty_call("glob_paths", '{"pattern": "**/*.py"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Glob '**/*.py' in '.' (2 paths)" in out
async def test_pretty_glob_no_matches(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_pretty_call("glob_paths", '{"pattern": "**/*.rs"}'), _result("(no matches)")]))
out = capsys.readouterr().out
assert "* Glob '**/*.rs' in '.' (no matches)" in out
async def test_pretty_glob_truncated(capsys: pytest.CaptureFixture[str]) -> None:
result = "a\nb\n...[truncated at 200 matches]"
await render_events(_aiter([_pretty_call("glob_paths", '{"pattern": "*"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Glob '*' in '.' (2 paths)" in out
async def test_pretty_grep(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)]))
out = capsys.readouterr().out
assert "* Grep '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)")]))
out = capsys.readouterr().out
assert "* Grep 'zzz' in '.' (no matches)" in out
async def test_pretty_run_argv_success(capsys: pytest.CaptureFixture[str]) -> None:
result = (
"exit_code=0\ntimed_out=false\ncwd=.\ncmd=git status --short\n--- stdout ---\n M src/foo.py\n--- stderr ---\n"
)
await render_events(_aiter([_pretty_call("run_argv", '{"argv": ["git", "status", "--short"]}'), _result(result)]))
out = capsys.readouterr().out
assert "* Run $ git status --short (exit code 0)" in out
assert "[tool]" not in out
assert "[tool ok]" not in out
async def test_pretty_run_argv_failure(capsys: pytest.CaptureFixture[str]) -> None:
result = (
"exit_code=1\ntimed_out=false\ncwd=.\ncmd=ls /nope\n--- stdout ---\n--- stderr ---\nls: cannot access '/nope'\n"
)
await render_events(_aiter([_pretty_call("run_argv", '{"argv": ["ls", "/nope"]}'), _result(result)]))
out = capsys.readouterr().out
assert "* Run $ ls /nope (exit code 1)" in out
async def test_pretty_run_argv_timed_out(capsys: pytest.CaptureFixture[str]) -> None:
result = "exit_code=\ntimed_out=true\ncwd=.\ncmd=sleep 10\n--- stdout ---\n--- stderr ---\n"
await render_events(_aiter([_pretty_call("run_argv", '{"argv": ["sleep", "10"]}'), _result(result)]))
out = capsys.readouterr().out
assert "* Run $ sleep 10 (timed out)" in out
async def test_pretty_run_argv_error(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(
_aiter([_pretty_call("run_argv", '{"argv": ["nope"]}'), _result("error: executable not found: 'nope'")])
)
out = capsys.readouterr().out
assert "* Run $ nope (error: executable not found: 'nope')" in out
async def test_pretty_run_argv_batch_stopped(capsys: pytest.CaptureFixture[str]) -> None:
result = (
"steps=3 ran=2 stop_on_error=true stopped_early=true\n"
"--- step 1 ---\nexit_code=1\ntimed_out=false\ncwd=.\ncmd=git status\n"
"pipe_out=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
"--- step 2 ---\nexit_code=0\ntimed_out=false\ncwd=.\ncmd=git diff\n"
"pipe_out=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
"--- summary ---\nlast_exit=1\n"
)
await render_events(_aiter([_pretty_call("run_argv_batch", '{"steps": []}'), _result(result)]))
out = capsys.readouterr().out
assert "* Run batch (stopped early)" in out
async def test_pretty_run_argv_batch_complete(capsys: pytest.CaptureFixture[str]) -> None:
result = (
"steps=2 ran=2 stop_on_error=true stopped_early=false\n"
"--- step 1 ---\nexit_code=0\n--- stdout ---\n--- stderr ---\n"
"--- step 2 ---\nexit_code=0\n--- stdout ---\n--- stderr ---\n"
"--- summary ---\nlast_exit=0\n"
)
await render_events(_aiter([_pretty_call("run_argv_batch", '{"steps": []}'), _result(result)]))
out = capsys.readouterr().out
assert "* Run batch (done)" in out
async def test_pretty_fetch(capsys: pytest.CaptureFixture[str]) -> None:
result = (
"status=200\nmethod=GET\nfinal_url=https://example.com\ncontent_type=application/json\n"
"body_kind=json\nbytes=1229\ntruncated=false\nredirects=0\nsecurity=public\n--- body ---\n{...}\n"
)
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Fetch GET https://example.com (200)" in out
assert "[tool]" not in out
async def test_pretty_fetch_error_status(capsys: pytest.CaptureFixture[str]) -> None:
result = (
"status=404\nmethod=GET\nfinal_url=https://example.com/nope\ncontent_type=text/html\n"
"body_kind=html\nbytes=45\ntruncated=false\nredirects=0\nsecurity=public\n--- body ---\nNot found\n"
)
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com/nope"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Fetch GET https://example.com/nope (404)" in out
async def test_pretty_fetch_truncated(capsys: pytest.CaptureFixture[str]) -> None:
result = (
"status=200\nmethod=GET\nfinal_url=https://example.com\ncontent_type=text/plain\n"
"body_kind=text\nbytes=50000\ntruncated=true\nredirects=0\nsecurity=public\n--- body ---\n..."
)
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Fetch GET https://example.com (200)" in out
async def test_pretty_fetch_error(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(
_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result("error: fetch failed: boom")])
)
out = capsys.readouterr().out
assert "* Fetch GET https://example.com (error: fetch failed: boom)" in out
async def test_pretty_mutators(capsys: pytest.CaptureFixture[str]) -> None:
cases = [
("write_file", '{"path": "src/x.py"}', "wrote 120 characters to src/x.py", "* Write 'src/x.py' (120 chars)"),
(
"edit_replace",
'{"path": "src/a.py"}',
"replaced 1 occurrence in src/a.py ('x' → 'y')",
"* Edit 'src/a.py' (done)",
),
(
"edit_lineno",
'{"path": "src/a.py"}',
"replaced lines 3-5 (3 lines; first: 'pass') with 2 lines in src/a.py",
"* Edit 'src/a.py' (done)",
),
(
"copy_path",
'{"src": "a.txt", "dst": "b.txt"}',
"copied file a.txt -> b.txt",
"* Copy 'a.txt' → 'b.txt' (done)",
),
("move_path", '{"src": "src", "dst": "dst"}', "moved directory src -> dst", "* Move 'src' → 'dst' (done)"),
("delete_path", '{"path": "tmp/x.txt"}', "deleted file tmp/x.txt", "* Delete 'tmp/x.txt' (done)"),
]
for name, args, content, expected in cases:
await render_events(_aiter([_pretty_call(name, args), _result(content)]))
out = capsys.readouterr().out
assert expected in out, f"{name}: expected {expected!r} in {out!r}"
assert "[tool ok]" not in out
async def test_pretty_mutator_error(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(
_aiter([_pretty_call("copy_path", '{"src": "x", "dst": "y"}'), _result("error: source does not exist: x")])
)
out = capsys.readouterr().out
assert "* Copy 'x' → 'y' (error: source does not exist: x)" in out
async def test_pretty_vcs_status_done(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_pretty_call("vcs_status", "{}"), _result("(clean)")]))
out = capsys.readouterr().out
assert "* VCS Status (done)" in out
assert "[tool]" not in out
async def test_pretty_vcs_status_error(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(
_aiter(
[
_pretty_call("vcs_status", "{}"),
_result("error: no supported VCS detected under workspace (currently: git)"),
]
)
)
out = capsys.readouterr().out
assert "* VCS Status (error: no supported VCS detected under workspace (currently: git))" in out
async def test_pretty_vcs_log_count(capsys: pytest.CaptureFixture[str]) -> None:
result = (
"5436457 2026-08-15 worldmozara core/tools: allow edit_lineno append\nabc1234 2026-08-14 worldmozara cli: fix\n"
)
await render_events(_aiter([_pretty_call("vcs_log", '{"limit": 2}'), _result(result)]))
out = capsys.readouterr().out
assert "* VCS Log (2 commits)" in out
async def test_pretty_vcs_log_single(capsys: pytest.CaptureFixture[str]) -> None:
content = "abc1234 2026-08-14 worldmozara x\n"
await render_events(_aiter([_pretty_call("vcs_log", '{"limit": 1}'), _result(content)]))
out = capsys.readouterr().out
assert "* VCS Log (1 commit)" in out
async def test_pretty_vcs_log_no_commits(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_pretty_call("vcs_log", "{}"), _result("(no commits)")]))
out = capsys.readouterr().out
assert "* VCS Log (no commits)" in out
async def test_pretty_wait_waited(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_pretty_call("wait", '{"duration": 5}'), _result("waited 5s")]))
out = capsys.readouterr().out
assert "* Wait (5s)" in out
assert "[tool]" not in out
async def test_pretty_wait_disturbed(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_pretty_call("wait", '{"duration": 30}'), _result("disturbed by user: ship it")]))
out = capsys.readouterr().out
assert "* Wait (disturbed)" 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)]))
out = capsys.readouterr().out
assert "* Wait (error: duration must not be negative)" in out
+156
View File
@@ -0,0 +1,156 @@
"""CLI slash ``/grants``, session hydrate/persist, and the access confirm hook."""
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.tools import AccessMode, grant_session_access
from plyngent.tools.access import AccessDecision
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from pathlib import Path
async def _new_state(
tmp_path: Path,
*,
document: object | None = None,
) -> tuple[ReplState, MemoryStore]:
memory = await MemoryStore.open(DatabaseConfig())
provider = OpenAIProvider(access_key_or_token="sk-test")
config = ConfigStore(
path=tmp_path / "plyngent.toml",
document=tomlkit.document() if document is None else document,
)
config.providers = {"local": provider}
state = ReplState(
config=config,
memory=memory,
workspace=tmp_path,
provider_name="local",
provider=provider,
model="gpt-test",
tools_enabled=False,
)
await state.new_session("t")
return state, memory
@pytest.fixture
async def state(tmp_path: Path) -> AsyncIterator[ReplState]:
st, memory = await _new_state(tmp_path)
yield st
await memory.close()
async def test_grants_list_empty(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
assert await handle_slash(state, "/grants") is True
assert "no directory-access grants" in capsys.readouterr().out
async def test_grants_list_and_revoke_session(
state: ReplState,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
outside = (tmp_path.parent / "outside-grant").resolve()
outside.mkdir(exist_ok=True)
_ = grant_session_access(outside, AccessMode.READ, session=state.session_state)
state.instance_state.workspace.yolo_allow[outside] = AccessMode.EXEC
state.instance_state.workspace.config_allow[tmp_path] = AccessMode.READ
assert await handle_slash(state, "/grants list") is True
out = capsys.readouterr().out
assert "directory-access grants (3)" in out
assert "session" in out
assert "process" in out
assert "config" in out
# Rows are ordered session, process, config.
assert await handle_slash(state, "/grants revoke 0") is True
assert "revoked" in capsys.readouterr().out
assert state.session_state.access_grants == {}
assert await state.memory.get_session_access_grants(state.session_id) == {}
async def test_grants_revoke_all_keeps_config(
state: ReplState,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
outside = (tmp_path.parent / "outside-all").resolve()
outside.mkdir(exist_ok=True)
_ = grant_session_access(outside, AccessMode.READ, session=state.session_state)
state.instance_state.workspace.yolo_allow[outside] = AccessMode.WRITE
state.instance_state.workspace.config_allow[tmp_path] = AccessMode.READ
assert await handle_slash(state, "/grants revoke all") is True
assert "config grants unchanged" in capsys.readouterr().out
assert state.session_state.access_grants == {}
assert state.instance_state.workspace.yolo_allow == {}
assert state.instance_state.workspace.config_allow == {tmp_path: AccessMode.READ}
async def test_grants_revoke_config_points_at_toml(
state: ReplState,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
state.instance_state.workspace.config_allow[tmp_path] = AccessMode.READ
assert await handle_slash(state, "/grants revoke 0") is True
assert "edit [agent].allow_paths" in capsys.readouterr().out
assert state.instance_state.workspace.config_allow == {tmp_path: AccessMode.READ}
async def test_grants_revoke_bad_index(
state: ReplState,
capsys: pytest.CaptureFixture[str],
) -> None:
assert await handle_slash(state, "/grants revoke 99") is True
assert "no grant at index 99" in capsys.readouterr().err
async def test_persist_and_load_roundtrip(state: ReplState, tmp_path: Path) -> None:
outside = (tmp_path.parent / "outside-hydrate").resolve()
outside.mkdir(exist_ok=True)
_ = grant_session_access(outside, AccessMode.WRITE, session=state.session_state)
await state.persist_access_grants()
state.session_state.access_grants.clear()
await state.load_access_grants()
assert state.session_state.access_grants == {outside: AccessMode.WRITE}
def test_yolo_hook_auto_approves_as_process_grant(state: ReplState, tmp_path: Path) -> None:
state.set_yolo("on")
decision = state.directory_access_confirm_hook(tmp_path, AccessMode.EXEC, "", 30.0)
assert decision == AccessDecision(AccessMode.EXEC, persist=False)
def test_default_hook_denies_noninteractive(state: ReplState, tmp_path: Path) -> None:
with temporary_backend(NonInteractiveBackend()):
assert state.directory_access_confirm_hook(tmp_path, AccessMode.READ, "", 1.0) is None
async def test_config_allow_paths_installed(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
outside = (tmp_path.parent / "outside-config").resolve()
outside.mkdir(exist_ok=True)
document = tomlkit.parse(f'[agent]\nallow_paths = {{ "{outside}" = "read", "/does/not/exist" = "read" }}\n')
state, memory = await _new_state(tmp_path, document=document)
try:
assert state.instance_state.workspace.config_allow == {outside: AccessMode.READ}
assert "ignored allow_paths entries" in capsys.readouterr().err
finally:
await memory.close()
+14 -10
View File
@@ -16,6 +16,8 @@ from plyngent.cli.interrupt import (
)
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
@@ -39,12 +41,16 @@ def test_nested_pause_depth() -> None:
def test_prompt_continue_limit_under_pause(monkeypatch: pytest.MonkeyPatch) -> None:
def _confirm(*_a: object, **_k: object) -> bool:
assert allow_task_cancel() is False
return True
backend = ScriptedBackend(["y"])
monkeypatch.setattr("click.confirm", _confirm)
assert prompt_continue_limit("too many rounds") is True
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:
@@ -60,11 +66,9 @@ async def test_run_in_prompt_thread_pauses_cancel() -> None:
async def test_prompt_continue_limit_async(monkeypatch: pytest.MonkeyPatch) -> None:
def _confirm(*_a: object, **_k: object) -> bool:
return True
monkeypatch.setattr("click.confirm", _confirm)
assert await prompt_continue_limit_async("too many rounds") is True
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:
+115 -3
View File
@@ -1,28 +1,106 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from plyngent.cli.limits import (
auto_continue_enabled,
format_tool_confirm_box,
install_cli_limit_hooks,
prompt_confirm_tool,
prompt_continue_limit,
reset_auto_continue,
reset_auto_continue_turn,
set_auto_continue_default,
)
from plyngent.prompting import get_prompt_backend, temporary_backend
from plyngent.prompting import NonInteractiveBackend, get_prompt_backend, temporary_backend
from plyngent.tools.process.pty_session import PtyManager
from tests.test_prompting import ScriptedBackend
if TYPE_CHECKING:
from collections.abc import Iterator
@pytest.fixture
def clean_auto_continue() -> Iterator[None]:
reset_auto_continue()
yield
reset_auto_continue()
def test_prompt_continue_limit_yes() -> None:
backend = ScriptedBackend([], confirms=[True])
backend = ScriptedBackend(["y"])
with temporary_backend(backend):
assert prompt_continue_limit("hit a wall") is True
def test_prompt_continue_limit_default_yes() -> None:
backend = ScriptedBackend([])
with temporary_backend(backend):
assert prompt_continue_limit("hit a wall") is True
def test_prompt_continue_limit_no() -> None:
backend = ScriptedBackend([], confirms=[False])
backend = ScriptedBackend(["n"])
with temporary_backend(backend):
assert prompt_continue_limit("hit a wall") is False
def test_prompt_continue_limit_yyy_sets_turn_skip(clean_auto_continue: None) -> None:
del clean_auto_continue
backend = ScriptedBackend(["yyy"])
with temporary_backend(backend):
assert prompt_continue_limit("hit a wall") is True
assert auto_continue_enabled() is True
# No further prompt: the script is empty and must not be consumed.
assert prompt_continue_limit("hit a wall again") is True
def test_reset_auto_continue_turn_clears_yyy(clean_auto_continue: None) -> None:
del clean_auto_continue
with temporary_backend(ScriptedBackend(["yyy"])):
assert prompt_continue_limit("limit") is True
reset_auto_continue_turn()
assert auto_continue_enabled() is False
with temporary_backend(ScriptedBackend(["n"])):
assert prompt_continue_limit("limit") is False
def test_auto_continue_default_short_circuits_prompt(clean_auto_continue: None) -> None:
del clean_auto_continue
set_auto_continue_default(enabled=True)
assert auto_continue_enabled() is True
with temporary_backend(NonInteractiveBackend()):
assert prompt_continue_limit("limit") is True
def test_prompt_continue_limit_noninteractive_denies() -> None:
with temporary_backend(NonInteractiveBackend()):
assert prompt_continue_limit("limit") is False
def test_setup_hooks_installs_pty_hook_for_auto_continue(clean_auto_continue: None) -> None:
del clean_auto_continue
from plyngent.cli.app import _setup_hooks
_setup_hooks(interactive=False, auto_continue=True)
assert PtyManager._limit_continue is not None
_setup_hooks(interactive=False, auto_continue=False)
assert PtyManager._limit_continue is None
def test_pty_offer_raise_uses_auto_continue(clean_auto_continue: None) -> None:
del clean_auto_continue
PtyManager.set_limit_continue_hook(prompt_continue_limit)
set_auto_continue_default(enabled=True)
try:
with temporary_backend(NonInteractiveBackend()):
assert PtyManager._offer_raise("PTY session limit reached") is True
finally:
PtyManager.set_limit_continue_hook(None)
def test_prompt_confirm_tool_default_deny() -> None:
backend = ScriptedBackend([], confirms=[False])
with temporary_backend(backend):
@@ -54,3 +132,37 @@ def test_policy_confirm_noninteractive_denies() -> None:
with temporary_backend(NonInteractiveBackend()):
assert prompt_policy_command_confirm("sudo", ["sudo", "id"], 1.0) is False
def test_directory_access_prompt_noninteractive_denies() -> None:
from pathlib import Path
from plyngent.cli.limits import prompt_directory_access_confirm
from plyngent.prompting import NonInteractiveBackend
from plyngent.tools.workspace import AccessMode
with temporary_backend(NonInteractiveBackend()):
assert prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 1.0) is None
def test_directory_access_prompt_yes_and_level(monkeypatch: pytest.MonkeyPatch) -> None:
from pathlib import Path
from plyngent.cli import limits
from plyngent.tools.access import AccessDecision
from plyngent.tools.workspace import AccessMode
target = Path("/tmp")
with temporary_backend(ScriptedBackend([])):
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "y\n")
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == AccessDecision(
AccessMode.READ
)
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "exec\n")
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == AccessDecision(
AccessMode.EXEC
)
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "n\n")
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) is None
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: None)
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) is None
+230
View File
@@ -0,0 +1,230 @@
"""CLI slash ``/mcp`` command and MCP wiring in ``ReplState``."""
from __future__ import annotations
import json
import sys
from pathlib import Path
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, McpConfig, McpServerConfig, OpenAIProvider
from plyngent.config.store import ConfigStore
from plyngent.memory import MemoryStore
from plyngent.runtime.mcp_client import McpManager
if TYPE_CHECKING:
from collections.abc import AsyncIterator
_FAKE_SERVER = Path(__file__).parent.parent / "test_runtime" / "mcp_fake_server.py"
def _server_config(**overrides: object) -> McpServerConfig:
base: dict[str, object] = {
"command": sys.executable,
"args": [str(_FAKE_SERVER)],
"timeout": 5.0,
}
return McpServerConfig(**{**base, **overrides}) # type: ignore[arg-type]
@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,
)
await st.new_session("t")
yield st
await memory.close()
async def test_slash_mcp_list_without_manager(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
assert await handle_slash(state, "/mcp") is True
assert "no servers configured" in capsys.readouterr().out
assert await handle_slash(state, "/mcp list") is True
assert "no servers configured" in capsys.readouterr().out
async def test_slash_mcp_list_statuses(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[]))
await manager.ensure_started()
state.mcp_manager = manager
try:
assert await handle_slash(state, "/mcp list") is True
out = capsys.readouterr().out
assert "fake" in out
assert "connected" in out
assert "tools=3" in out
finally:
await manager.aclose()
async def test_slash_mcp_reconnect_connects_new_config(
state: ReplState,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> None:
# The fixture's config file does not exist yet; write one with an MCP server.
payload = (
"[mcp.servers.fake]\n"
f"command = {json.dumps(sys.executable)}\n"
f"args = [{json.dumps(str(_FAKE_SERVER))}]\n"
"timeout = 5.0\n"
)
_ = (tmp_path / "plyngent.toml").write_text(payload, encoding="utf-8")
assert await handle_slash(state, "/mcp reconnect") is True
try:
assert state.mcp_manager is not None
statuses = state.mcp_manager.statuses()
assert [name for name, _, _ in statuses] == ["fake"]
assert statuses[0][1] == "connected"
assert statuses[0][2] == 3
out = capsys.readouterr().out
assert "tools=off" in out
assert "fake" in out
finally:
if state.mcp_manager is not None:
await state.mcp_manager.aclose()
async def test_mcp_tools_registered_on_tools_rebuild(state: ReplState) -> None:
manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[]))
await manager.ensure_started()
state.mcp_manager = manager
try:
from plyngent.tools.catalog import catalog_scope
state.tools_enabled = True
with catalog_scope(empty=True):
state.rebuild_client()
registry = state.agent.tools
assert registry is not None
assert registry.get("mcp__fake__echo") is not None
assert registry.get("mcp__fake__fail") is not None
# Handler proxies to the live server subprocess.
out = await registry.execute("mcp__fake__echo", '{"text": "hi"}')
assert out == "echo: hi"
finally:
await manager.aclose()
async def test_slash_mcp_list_shows_instructions(
state: ReplState,
capsys: pytest.CaptureFixture[str],
) -> None:
guidance = "Prefer `echo` for repeating text back."
manager = McpManager(
McpConfig(
servers={
"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
},
disable=[],
)
)
await manager.ensure_started()
state.mcp_manager = manager
try:
assert await handle_slash(state, "/mcp list") is True
out = capsys.readouterr().out
assert f"instructions ({len(guidance)} chars)" in out
assert guidance in out
finally:
await manager.aclose()
async def _make_tools_state(
tmp_path: Path,
*,
agent_toml: str,
manager: McpManager,
) -> tuple[ReplState, MemoryStore]:
"""ReplState with tools on + a started MCP manager (agent built from TOML)."""
memory = await MemoryStore.open(DatabaseConfig())
provider = OpenAIProvider(access_key_or_token="sk-test")
document = tomlkit.parse(agent_toml) if agent_toml else tomlkit.document()
config = ConfigStore(path=tmp_path / "plyngent.toml", document=document)
config.providers = {"local": provider}
st = ReplState(
config=config,
memory=memory,
workspace=tmp_path,
provider_name="local",
provider=provider,
model="gpt-test",
tools_enabled=True,
mcp_manager=manager,
)
return st, memory
async def test_mcp_instructions_folded_into_agent_system_prompt(tmp_path: Path) -> None:
from plyngent.tools.catalog import catalog_scope
guidance = "Prefer `echo` for repeating text back to the user."
manager = McpManager(
McpConfig(
servers={
"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
},
disable=[],
)
)
await manager.ensure_started()
try:
with catalog_scope(empty=True):
state, memory = await _make_tools_state(tmp_path, agent_toml="", manager=manager)
try:
prompt = state.agent.system_prompt
assert prompt is not None
assert "MCP server 'fake' instructions:" in prompt
assert guidance in prompt
finally:
await memory.close()
finally:
await manager.aclose()
async def test_mcp_instructions_flag_off_skips_server_text(tmp_path: Path) -> None:
from plyngent.tools.catalog import catalog_scope
guidance = "Prefer `echo` for repeating text back to the user."
manager = McpManager(
McpConfig(
servers={
"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
},
disable=[],
)
)
await manager.ensure_started()
try:
with catalog_scope(empty=True):
state, memory = await _make_tools_state(
tmp_path,
agent_toml="[agent]\nmcp_instructions = false\n",
manager=manager,
)
try:
prompt = state.agent.system_prompt
assert prompt is not None
assert guidance not in prompt
assert "MCP server" not in prompt
assert "### Workspace" in prompt # host tool playbook still composed
finally:
await memory.close()
finally:
await manager.aclose()
+131
View File
@@ -94,6 +94,32 @@ async def test_help_and_clear(state: ReplState) -> None:
assert state.agent.messages == []
async def test_auto_continue_limits_installs_on_limit(tmp_path: Path) -> None:
from plyngent.cli.limits import auto_continue_enabled, reset_auto_continue
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}
try:
st = ReplState(
config=config,
memory=memory,
workspace=tmp_path,
provider_name="local",
provider=provider,
model="gpt-test",
tools_enabled=False,
interactive_limits=False,
auto_continue_limits=True,
)
assert auto_continue_enabled() is True
assert st.agent.on_limit is not None
finally:
reset_auto_continue()
await memory.close()
async def test_help_command_usage_line(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
assert await handle_slash(state, "/help compact") is True
out = capsys.readouterr().out
@@ -633,3 +659,108 @@ async def test_repl_second_ctrl_c_after_cancel_returns_to_prompt(
assert calls["reads"] == 4 # three entries + EOF
assert calls["echoes"] == 3 # the interrupting echo still counted, loop survived
assert "interrupted" in capsys.readouterr().out
async def test_btw_default_read_falls_back_when_tools_off(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
"""Bare /btw (default --tools=read) still works under /tools off: no tools."""
assert state.tools_enabled is False
state.agent.stream = False # DummyClient only completes via non-stream path
assert await handle_slash(state, "/btw hello") is True
out = capsys.readouterr().out
assert "btw: hello" in out
assert "btw done" in out
assert "error" not in out
async def test_btw_explicit_tools_errors_when_off(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
"""Explicitly requesting read/full tools while they are off is an error."""
state.agent.stream = False # DummyClient only completes via non-stream path
assert await handle_slash(state, "/btw --tools read q") is True
assert "error: --tools requires tools on" in capsys.readouterr().out
assert await handle_slash(state, "/btw --tools full q") is True
assert "error: --tools requires tools on" in capsys.readouterr().out
# Explicit no is fine.
assert await handle_slash(state, "/btw --tools no q") is True
out = capsys.readouterr().out
assert "btw done" in out
assert "error" not in out
async def test_btw_read_mode_wires_read_only_clone(
state: ReplState,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""--tools=read passes a read-only clone + read-only context + main session."""
from plyngent.agent import ToolRegistry, ToolTag, tool
from plyngent.tools.context import is_read_only_context
@tool(tags=ToolTag.LOCAL | ToolTag.READ_ONLY, register=False)
async def ro_tool() -> str:
return "ro"
@tool(tags=ToolTag.LOCAL, register=False)
async def w_tool() -> str:
return "w"
state.tools_enabled = True
state.agent.tools = ToolRegistry([ro_tool, w_tool], auto_bind_state=True)
captured: dict[str, object] = {}
async def fake_run_aside(_text: str, **kwargs: object) -> AsyncIterator[object]:
captured["tools"] = kwargs.get("tools")
captured["session_state"] = kwargs.get("session_state")
captured["read_only"] = is_read_only_context()
if False:
yield None
monkeypatch.setattr(state.agent, "run_aside", fake_run_aside)
assert await handle_slash(state, "/btw --tools read hi") is True
assert "btw done" in capsys.readouterr().out
tools = captured["tools"]
assert isinstance(tools, ToolRegistry)
assert {t.name for t in tools.definitions()} == {"ro_tool"}
assert captured["session_state"] is state.session_state
assert captured["read_only"] is True
async def test_btw_full_mode_clones_full_registry(
state: ReplState,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""--tools=full keeps the whole registry with a fresh session, no context."""
from plyngent.agent import ToolRegistry, ToolTag, tool
from plyngent.tools.context import is_read_only_context
@tool(tags=ToolTag.LOCAL | ToolTag.READ_ONLY, register=False)
async def ro_tool() -> str:
return "ro"
@tool(tags=ToolTag.LOCAL, register=False)
async def w_tool() -> str:
return "w"
state.tools_enabled = True
state.agent.tools = ToolRegistry([ro_tool, w_tool], auto_bind_state=True)
captured: dict[str, object] = {}
async def fake_run_aside(_text: str, **kwargs: object) -> AsyncIterator[object]:
captured["tools"] = kwargs.get("tools")
captured["session_state"] = kwargs.get("session_state")
captured["read_only"] = is_read_only_context()
if False:
yield None
monkeypatch.setattr(state.agent, "run_aside", fake_run_aside)
assert await handle_slash(state, "/btw --tools full hi") is True
assert "btw done" in capsys.readouterr().out
assert captured["tools"] is True
assert captured["session_state"] is not state.session_state
assert captured["read_only"] is False
+39
View File
@@ -30,6 +30,8 @@ from plyngent.memory import MemoryStore
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from plyngent.agent import AgentEvent
def test_default_retry_delays_schedule() -> None:
assert DEFAULT_MAX_AUTO_RETRIES == 10
@@ -296,6 +298,43 @@ async def test_second_ctrl_c_during_retry_announce_stays_benign(
assert await _wait_for_retry(1, 3, 0.01) is True
async def test_turn_boundaries_reset_auto_continue() -> None:
from plyngent.cli.limits import (
auto_continue_enabled,
prompt_continue_limit,
reset_auto_continue,
)
from plyngent.prompting import temporary_backend
from tests.test_prompting import ScriptedBackend
reset_auto_continue()
try:
with temporary_backend(ScriptedBackend(["yyy"])):
assert prompt_continue_limit("limit") is True
assert auto_continue_enabled() is True
store = await MemoryStore.open(DatabaseConfig())
try:
session = await store.create_session(name="t")
client = FlakyClient(fail_times=0)
agent = ChatAgent(client, model="m", memory=store, session_id=session.sid)
seen: list[bool] = []
async def starter() -> AsyncIterator[AgentEvent]:
seen.append(auto_continue_enabled())
async for event in agent.run("hi"):
yield event
ok = await run_turn_with_retries(agent, starter=starter, delays=())
assert ok is True
assert seen == [False] # cleared before the turn starts
assert auto_continue_enabled() is False # cleared after the turn ends
finally:
await store.close()
finally:
reset_auto_continue()
async def test_manual_retry_after_exhausted(monkeypatch: pytest.MonkeyPatch) -> None:
async def no_wait(_seconds: float) -> bool:
return True
+48
View File
@@ -19,12 +19,15 @@ def test_agent_section_defaults(tmp_path: Path) -> None:
store = load(path)
assert store.agent_config.system_prompt == DEFAULT_SYSTEM_PROMPT
assert store.agent_config.tool_directives == DEFAULT_TOOL_DIRECTIVES
assert store.agent_config.mcp_instructions is True
assert "professional coding agent" in store.agent_config.system_prompt
assert "### Workspace" in store.agent_config.tool_directives
assert store.agent_config.max_tool_result_chars == 32_000
assert store.agent_config.parallel_tools is True
assert store.agent_config.confirm_destructive is True
assert store.agent_config.path_denylist == []
assert store.agent_config.allow_paths == {}
assert store.agent_config.auto_continue_limits is False
assert store.agent_config.max_context_tokens == 200_000
assert store.plugins_config.enable == []
assert store.plugins_config.disable == []
@@ -64,6 +67,9 @@ def test_compose_empty_combinations() -> None:
assert compose_agent_system_content("Only persona", "") == "Only persona"
assert compose_agent_system_content("", "Only tools") == "Only tools"
assert compose_agent_system_content("Persona", "Tools") == "Persona\n\nTools"
assert compose_agent_system_content("", "", "") is None
assert compose_agent_system_content("Persona", "Tools", "Extra") == "Persona\n\nTools\n\nExtra"
assert compose_agent_system_content("Persona", "", "Extra") == "Persona\n\nExtra"
def test_agent_section_parse(tmp_path: Path) -> None:
@@ -78,6 +84,7 @@ parallel_tools = false
confirm_destructive = false
path_denylist = ["/secrets/", ".ssh/"]
max_context_tokens = 5000
auto_continue_limits = true
""",
encoding="utf-8",
)
@@ -89,6 +96,7 @@ max_context_tokens = 5000
assert store.agent_config.confirm_destructive is False
assert store.agent_config.path_denylist == ["/secrets/", ".ssh/"]
assert store.agent_config.max_context_tokens == 5000
assert store.agent_config.auto_continue_limits is True
composed = compose_agent_system_content(
store.agent_config.system_prompt,
store.agent_config.tool_directives,
@@ -158,6 +166,46 @@ tool_directives = ""
)
def test_agent_allow_paths_parse(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text(
"""
[agent]
allow_paths = { "/data/datasets" = "read", "/tmp/build" = "exec" }
""",
encoding="utf-8",
)
store = load(path)
assert store.agent_config.allow_paths == {"/data/datasets": "read", "/tmp/build": "exec"}
def test_agent_allow_paths_invalid_mode_falls_back(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text(
"""
[agent]
allow_paths = { "/data" = "sudo" }
""",
encoding="utf-8",
)
store = load(path)
# Invalid field value resets the whole [agent] section to defaults.
assert store.agent_config.allow_paths == {}
def test_agent_mcp_instructions_flag(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text(
"""
[agent]
mcp_instructions = false
""",
encoding="utf-8",
)
store = load(path)
assert store.agent_config.mcp_instructions is False
def test_plugins_section_parse(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text(
+66
View File
@@ -0,0 +1,66 @@
"""[mcp] section parse: server definitions, disable gate, malformed fallback."""
from __future__ import annotations
from plyngent.config import load
def test_mcp_defaults_and_parse(tmp_path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text("", encoding="utf-8")
store = load(path)
assert store.mcp_config.servers == {}
assert store.mcp_config.disable == []
path.write_text(
"""
[mcp.servers.docs]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
env = { RUST_LOG = "info" }
timeout = 10.0
read_only = true
[mcp.servers.search]
command = "uvx"
args = ["mcp-server-fetch"]
[mcp]
disable = ["search"]
""",
encoding="utf-8",
)
store = load(path)
mcp = store.mcp_config
assert set(mcp.servers) == {"docs", "search"}
docs = mcp.servers["docs"]
assert docs.command == "npx"
assert docs.args == ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
assert docs.env == {"RUST_LOG": "info"}
assert docs.timeout == 10.0
assert docs.read_only is True
assert docs.url == ""
assert mcp.disable == ["search"]
def test_mcp_malformed_section_falls_back_to_defaults(tmp_path) -> None:
path = tmp_path / "c.toml"
# Unknown section key.
path.write_text("[mcp]\nenabled = true\n", encoding="utf-8")
store = load(path)
assert store.mcp_config.servers == {}
# Unknown server key (typo).
path.write_text('[mcp.servers.docs]\ncmds = "npx"\n', encoding="utf-8")
store = load(path)
assert store.mcp_config.servers == {}
# Server entry is not a table.
path.write_text("[mcp]\nservers = 3\n", encoding="utf-8")
store = load(path)
assert store.mcp_config.servers == {}
# Empty server name.
path.write_text('[mcp.servers.""]\ncommand = "npx"\n', encoding="utf-8")
store = load(path)
assert store.mcp_config.servers == {}
+23 -8
View File
@@ -34,6 +34,21 @@ async def test_create_session_uses_default_user(store: MemoryStore) -> None:
assert session.uid == user.uid
async def test_session_access_grants_roundtrip(store: MemoryStore) -> None:
session = await store.create_session(name="grants")
assert await store.get_session_access_grants(session.sid) == {}
_ = await store.update_session_access_grants(
session.sid,
{"/data/datasets": "read", "/tmp/build": "exec"},
)
assert await store.get_session_access_grants(session.sid) == {
"/data/datasets": "read",
"/tmp/build": "exec",
}
_ = await store.update_session_access_grants(session.sid, {})
assert await store.get_session_access_grants(session.sid) == {}
async def test_session_context_usage_roundtrip(store: MemoryStore) -> None:
session = await store.create_session(name="usage")
row = await store.update_session_context_usage(
@@ -293,9 +308,9 @@ async def test_schema_migrations_stamp_version(tmp_path: object) -> None:
db_path = tmp_path / "fresh.db"
store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
await store.close()
assert await _user_version(db_path) == 3
assert await _user_version(db_path) == 4
columns = await _session_columns(db_path)
assert {"workspace", "provider_name", "model", "todo_stack", "last_prompt_tokens"} <= columns
assert {"workspace", "provider_name", "model", "todo_stack", "access_grants", "last_prompt_tokens"} <= columns
async def test_legacy_db_migrated_to_latest(tmp_path: object) -> None:
@@ -311,9 +326,9 @@ async def test_legacy_db_migrated_to_latest(tmp_path: object) -> None:
session = await store.create_session(name="migrated")
await store.close()
assert await _user_version(db_path) == 3
assert await _user_version(db_path) == 4
columns = await _session_columns(db_path)
assert {"workspace", "provider_name", "model", "todo_stack", "last_prompt_tokens"} <= columns
assert {"workspace", "provider_name", "model", "todo_stack", "access_grants", "last_prompt_tokens"} <= columns
assert session.sid is not None
@@ -335,10 +350,10 @@ async def test_partially_migrated_legacy_db_noops_remaining_steps(tmp_path: obje
session = await store.create_session(name="partial", workspace=tmp_path)
await store.close()
assert await _user_version(db_path) == 3
assert await _user_version(db_path) == 4
columns = await _session_columns(db_path)
assert "workspace" in columns
assert {"provider_name", "model", "todo_stack", "last_prompt_tokens"} <= columns
assert {"provider_name", "model", "todo_stack", "access_grants", "last_prompt_tokens"} <= columns
assert session.workspace is not None
@@ -350,8 +365,8 @@ async def test_stamped_db_skips_migrations_on_reopen(tmp_path: object) -> None:
db_path = tmp_path / "stamped.db"
store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
await store.close()
assert await _user_version(db_path) == 3
assert await _user_version(db_path) == 4
store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
await store.close()
assert await _user_version(db_path) == 3
assert await _user_version(db_path) == 4
+103
View File
@@ -0,0 +1,103 @@
"""Minimal MCP stdio server used by the client tests (echo + sleep tools).
Set ``PLYNGENT_MCP_FAKE_INSTRUCTIONS`` (env) to have the server return that
text as ``InitializeResult.instructions`` (usage guidance).
"""
from __future__ import annotations
import json
import os
import sys
_FAKE_INSTRUCTIONS = os.environ.get("PLYNGENT_MCP_FAKE_INSTRUCTIONS", "")
def respond(request_id: int, result: object) -> None:
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}) + "\n")
sys.stdout.flush()
def main() -> None:
for line in sys.stdin:
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
method = payload.get("method", "")
request_id = payload.get("id")
if request_id is None:
continue # notification (notifications/initialized)
if method == "initialize":
result: dict[str, object] = {
"protocolVersion": "2025-06-18",
"capabilities": {"tools": {}},
"serverInfo": {"name": "fake-mcp", "version": "0.1.0"},
}
if _FAKE_INSTRUCTIONS:
result["instructions"] = _FAKE_INSTRUCTIONS
respond(int(request_id), result)
elif method == "tools/list":
respond(
int(request_id),
{
"tools": [
{
"name": "echo",
"description": "Echo the given text back.",
"inputSchema": {
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
},
{
"name": "fail",
"description": "Always reports a tool error.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "slow",
"description": "Sleeps past any client timeout.",
"inputSchema": {"type": "object", "properties": {}},
},
]
},
)
elif method == "tools/call":
name = (payload.get("params") or {}).get("name")
if name == "echo":
text = str((payload.get("params") or {}).get("arguments", {}).get("text", ""))
respond(
int(request_id),
{
"content": [{"type": "text", "text": f"echo: {text}"}],
"isError": False,
},
)
elif name == "fail":
respond(
int(request_id),
{"content": [{"type": "text", "text": "boom"}], "isError": True},
)
else:
import time
time.sleep(5)
respond(int(request_id), {"content": [], "isError": False})
else:
sys.stdout.write(
json.dumps(
{
"jsonrpc": "2.0",
"id": request_id,
"error": {"code": -32601, "message": f"unknown method {method}"},
}
)
+ "\n"
)
sys.stdout.flush()
if __name__ == "__main__":
main()
+140
View File
@@ -0,0 +1,140 @@
"""MCP stdio client + manager against the bundled fake server subprocess."""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from plyngent.config.models import McpConfig, McpServerConfig
from plyngent.runtime.mcp_client import (
McpConnectionError,
McpManager,
McpServerConnection,
)
_FAKE_SERVER = Path(__file__).parent / "mcp_fake_server.py"
def _server_config(**overrides: object) -> McpServerConfig:
base: dict[str, object] = {
"command": sys.executable,
"args": [str(_FAKE_SERVER)],
"timeout": 5.0,
}
return McpServerConfig(**{**base, **overrides}) # type: ignore[arg-type]
async def test_connection_handshake_lists_and_calls_tools() -> None:
connection = McpServerConnection("fake", _server_config())
await connection.start()
try:
assert connection.connected
assert connection.error is None
assert connection.instructions is None
assert [tool.name for tool in connection.tools] == ["echo", "fail", "slow"]
echo = connection.tools[0]
assert echo.description == "Echo the given text back."
assert echo.input_schema["type"] == "object"
result = await connection.call_tool("echo", {"text": "hello"})
assert result == "echo: hello"
finally:
await connection.aclose()
async def test_initialize_captures_server_instructions() -> None:
guidance = "Use echo to repeat text back to the user."
connection = McpServerConnection(
"fake",
_server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
)
await connection.start()
try:
assert connection.connected
assert connection.instructions == guidance
finally:
await connection.aclose()
async def test_tool_error_returns_error_text() -> None:
connection = McpServerConnection("fake", _server_config())
await connection.start()
try:
result = await connection.call_tool("fail", {})
assert result == "error: boom"
finally:
await connection.aclose()
async def test_request_timeout_raises() -> None:
connection = McpServerConnection("fake", _server_config(timeout=0.2))
await connection.start()
try:
with pytest.raises(McpConnectionError, match="timed out"):
await connection.call_tool("slow", {})
finally:
await connection.aclose()
async def test_spawn_failure_records_error() -> None:
connection = McpServerConnection("bad", _server_config(command="/nonexistent/plyngent-mcp-binary"))
await connection.start()
assert not connection.connected
assert connection.error is not None
assert "spawn" in connection.status
await connection.aclose()
async def test_close_terminates_subprocess() -> None:
connection = McpServerConnection("fake", _server_config())
await connection.start()
proc = connection._proc
assert proc is not None
await connection.aclose()
assert connection._proc is None
assert proc.returncode is not None
async def test_manager_start_status_and_call() -> None:
manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[]))
await manager.ensure_started()
try:
assert [(name, status, count) for name, status, count in manager.statuses()] == [("fake", "connected", 3)]
assert manager.instructions() == []
assert await manager.call("fake", "echo", {"text": "hi"}) == "echo: hi"
finally:
await manager.aclose()
assert manager.statuses() == [("fake", "not started", 0)]
async def test_manager_instructions_after_restart() -> None:
guidance = "Always answer with a haiku."
plain = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[]))
await plain.ensure_started()
assert plain.instructions() == []
await plain.restart(
McpConfig(
servers={"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance})},
disable=[],
)
)
await plain.ensure_started()
try:
assert plain.instructions() == [("fake", guidance)]
finally:
await plain.aclose()
async def test_manager_disable_and_restart() -> None:
manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=["fake"]))
await manager.ensure_started()
assert manager.connections() == []
with pytest.raises(McpConnectionError, match="not connected"):
await manager.call("fake", "echo", {})
await manager.restart(McpConfig(servers={"fake": _server_config()}, disable=[]))
await manager.ensure_started()
assert manager.statuses() == [("fake", "connected", 3)]
await manager.aclose()
+263
View File
@@ -0,0 +1,263 @@
"""``request_directory_access`` tool and the grant stores."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from plyngent.agent import ToolTag
from plyngent.tools import (
MAX_ACCESS_GRANTS,
AccessDecision,
AccessMode,
SessionState,
WorkspaceError,
active_workspace_policy,
get_directory_access_confirm_hook,
grant_session_access,
request_directory_access,
resolve_path,
set_config_access,
set_directory_access_confirm_hook,
set_path_denylist,
)
from plyngent.tools.context import bind_session
from tests.test_tools.helpers import call_async
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
@pytest.fixture
def session() -> Iterator[SessionState]:
state = SessionState()
with bind_session(state):
yield state
def _never_called(*_args: object) -> AccessDecision | str | None:
msg = "confirm hook should not be called"
raise AssertionError(msg)
def test_tool_tags_and_registry() -> None:
tags = request_directory_access.tags
assert tags & ToolTag.LOCAL
assert tags & ToolTag.INSTANCE_STATE
assert tags & ToolTag.SESSION_STATE
assert not (tags & ToolTag.READ_ONLY)
from plyngent.tools.catalog import default_tool_definitions
assert "request_directory_access" in {definition.name for definition in default_tool_definitions()}
def test_hook_roundtrip(workspace: object, session: SessionState) -> None:
del workspace, session
assert get_directory_access_confirm_hook() is None
set_directory_access_confirm_hook(_never_called)
assert get_directory_access_confirm_hook() is not None
set_directory_access_confirm_hook(None)
assert get_directory_access_confirm_hook() is None
async def test_approve_grants_session_access(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("approved").resolve()
calls: list[tuple[Path, AccessMode, str, float]] = []
def hook(path: Path, mode: AccessMode, reason: str, timeout: float) -> AccessDecision:
calls.append((path, mode, reason, timeout))
return AccessDecision(AccessMode.WRITE)
set_directory_access_confirm_hook(hook)
out = await call_async(request_directory_access, str(outside), "need the dataset", "read")
assert "access granted" in out
assert "write, session" in out
assert calls == [(outside, AccessMode.READ, "need the dataset", 30.0)]
assert session.access_grants[outside] == AccessMode.WRITE
target = outside / "data.txt"
_ = target.write_text("x", encoding="utf-8")
assert resolve_path(str(target), required=AccessMode.WRITE) == target
async def test_process_grant_when_not_persisting(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("process").resolve()
set_directory_access_confirm_hook(lambda *_a: AccessDecision(AccessMode.READ, persist=False))
out = await call_async(request_directory_access, str(outside), mode="read")
assert "read, process" in out
assert session.access_grants == {}
assert active_workspace_policy().yolo_allow[outside] == AccessMode.READ
async def test_deny_with_comment(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("denied").resolve()
set_directory_access_confirm_hook(lambda *_a: "no, that is my tax data")
out = await call_async(request_directory_access, str(outside))
assert out.startswith("error: access to")
assert "user comment: no, that is my tax data" in out
assert session.access_grants == {}
async def test_deny_none(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("timeout").resolve()
set_directory_access_confirm_hook(lambda *_a: None)
out = await call_async(request_directory_access, str(outside))
assert "denied (declined or timed out" in out
assert session.access_grants == {}
async def test_no_hook_denies(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("nohook").resolve()
set_directory_access_confirm_hook(None)
out = await call_async(request_directory_access, str(outside))
assert "no confirm hook installed" in out
async def test_denylist_can_never_be_granted(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("secret-zone").resolve()
secret = outside / "secret"
secret.mkdir()
set_path_denylist(["/secret/"])
set_directory_access_confirm_hook(_never_called)
try:
out = await call_async(request_directory_access, str(secret), mode="read")
assert "can never be granted" in out
assert session.access_grants == {}
finally:
set_path_denylist(None)
async def test_already_accessible_inside_workspace(
workspace: object,
session: SessionState,
) -> None:
from pathlib import Path
assert isinstance(workspace, Path)
set_directory_access_confirm_hook(_never_called)
out = await call_async(request_directory_access, str(workspace), mode="read")
assert "already accessible" in out
assert session.access_grants == {}
async def test_already_granted_skips_prompt(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("granted").resolve()
_ = grant_session_access(outside, AccessMode.READ)
set_directory_access_confirm_hook(_never_called)
out = await call_async(request_directory_access, str(outside), mode="read")
assert "already granted" in out
async def test_invalid_mode(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace, session
outside = tmp_path_factory.mktemp("mode").resolve()
out = await call_async(request_directory_access, str(outside), mode="sudo")
assert "invalid mode" in out
async def test_exec_requires_directory(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace, session
outside = tmp_path_factory.mktemp("exec-file").resolve()
target = outside / "f.txt"
_ = target.write_text("x", encoding="utf-8")
out = await call_async(request_directory_access, str(target), mode="exec")
assert "exec access requires a directory" in out
async def test_missing_path(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace, session
missing = tmp_path_factory.mktemp("missing").resolve() / "nope"
out = await call_async(request_directory_access, str(missing))
assert "does not exist" in out
async def test_grant_cap(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace
outside = tmp_path_factory.mktemp("cap").resolve()
policy = active_workspace_policy()
for index in range(MAX_ACCESS_GRANTS):
policy.config_allow[outside / f"d{index}"] = AccessMode.READ
set_directory_access_confirm_hook(_never_called)
try:
out = await call_async(request_directory_access, str(outside))
assert "too many active access grants" in out
assert session.access_grants == {}
finally:
policy.config_allow.clear()
def test_set_config_access_installs_and_skips(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
del workspace, session
outside = tmp_path_factory.mktemp("config-allow").resolve()
target = outside / "data.txt"
_ = target.write_text("x", encoding="utf-8")
missing = outside / "missing"
applied, skipped = set_config_access(
{
str(outside): "read",
str(missing): "read",
str(target): "sudo",
}
)
assert applied == [outside]
assert sorted(skipped) == sorted([str(missing), str(target)])
assert active_workspace_policy().config_allow == {outside: AccessMode.READ}
assert resolve_path(str(target), required=AccessMode.READ) == target
with pytest.raises(WorkspaceError, match="escapes"):
_ = resolve_path(str(target), required=AccessMode.WRITE)
+42 -2
View File
@@ -4,8 +4,9 @@ from __future__ import annotations
import inspect
from plyngent.agent import ToolTag, tool
from plyngent.agent import ToolRegistry, ToolTag, tool
from plyngent.tools import default_tool_definitions, register_builtin_tools
from plyngent.tools.access import ACCESS_TOOLS
from plyngent.tools.catalog import ToolCatalog, ToolSource, catalog_scope, get_catalog, registration_source
from plyngent.tools.chat import CHAT_TOOLS
from plyngent.tools.file import FILE_TOOLS
@@ -18,7 +19,7 @@ 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 = [*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, *NET_TOOLS]
assert sorted(t.name for t in selected) == sorted(t.name for t in groups)
assert len(selected) == len(groups)
@@ -42,6 +43,45 @@ def test_public_surface_is_subset() -> None:
assert "read_file" not in public
def test_read_only_classification() -> None:
"""READ_ONLY marks exactly the non-mutating builtins."""
register_builtin_tools()
read_only = {t.name for t in default_tool_definitions(surface="local") if t.tags & ToolTag.READ_ONLY}
assert read_only == {
"read_file",
"get_truncated",
"glob_paths",
"grep_files",
"listdir",
"tree",
"todo_list",
"wait",
"ask_user_line",
"ask_user_choice",
"ask_user_form",
"vcs_kind",
"vcs_status",
"vcs_diff",
"vcs_log",
"vcs_branch",
"fetch",
}
def test_registry_clone_read_only_only() -> None:
"""clone(read_only_only=True) keeps only READ_ONLY tools, nothing else."""
register_builtin_tools()
full = ToolRegistry(default_tool_definitions(surface="local"))
ro = full.clone(read_only_only=True)
assert 0 < len(ro) < len(full)
assert {t.name for t in ro.definitions()} == {t.name for t in full.definitions() if t.tags & ToolTag.READ_ONLY}
assert all(t.tags & ToolTag.READ_ONLY for t in ro.definitions())
assert "run_argv" not in ro
assert "edit_replace" not in ro
# Plain clone keeps every tool.
assert len(full.clone()) == len(full)
def test_catalog_scope_empty_isolates_registration() -> None:
with catalog_scope(empty=True) as catalog:
+48
View File
@@ -144,3 +144,51 @@ async def test_wait_tool_times_out(monkeypatch) -> None:
registry = ToolRegistry([wait])
out = await registry.execute("wait", '{"duration": 3}')
assert out == "waited 3s"
async def test_wait_tool_prompt_two_lines_with_reason(monkeypatch) -> None:
prompts: list[str] = []
monkeypatch.setattr(
wait_module,
"read_line_with_timeout",
lambda prompt, timeout: prompts.append(prompt) or None,
)
with temporary_backend(ScriptedBackend([])):
registry = ToolRegistry([wait])
out = await registry.execute("wait", '{"duration": 5, "reason": "tests are slow"}')
assert out == "waited 5s"
assert prompts == ["Waiting for 5s (tests are slow). Press Enter to disturb.\nReason (optional): "]
async def test_wait_tool_prompt_two_lines_no_reason(monkeypatch) -> None:
prompts: list[str] = []
monkeypatch.setattr(
wait_module,
"read_line_with_timeout",
lambda prompt, timeout: prompts.append(prompt) or None,
)
with temporary_backend(ScriptedBackend([])):
registry = ToolRegistry([wait])
out = await registry.execute("wait", '{"duration": 5}')
assert out == "waited 5s"
assert prompts == ["Waiting for 5s. Press Enter to disturb.\nReason (optional): "]
def test_wait_prompt_plain_when_not_a_tty() -> None:
"""Prompt styling is stripped when stdout is not a TTY (pytest capture)."""
prompt = wait_module._wait_prompt(5, reason="tests")
assert "\x1b[" not in prompt
assert prompt == "Waiting for 5s (tests). Press Enter to disturb.\nReason (optional): "
def test_wait_prompt_colored_on_tty(monkeypatch) -> None:
"""On a TTY the prompt is styled: cyan status, yellow input prompt."""
class _FakeStdout:
def isatty(self) -> bool:
return True
monkeypatch.setattr(wait_module.sys, "stdout", _FakeStdout())
prompt = wait_module._wait_prompt(5, reason="tests")
assert "\x1b[36m" in prompt # cyan status
assert "\x1b[33m" in prompt # yellow input prompt
+57 -3
View File
@@ -19,9 +19,38 @@ def test_edit_lineno_replace_middle(workspace: object) -> None:
_ = _read_lineno("a.txt")
out = call_sync(edit_lineno, "a.txt", 2, 3, "TWO\nTHREE\n")
assert "replaced lines 2-3" in out
assert "first: 'two'" in out # old-content excerpt for self-correction
assert call_sync(read_file, "a.txt") == "L1-4\none\nTWO\nTHREE\nfour\n"
def test_edit_lineno_read_truncated_marks_only_displayed_lines(workspace: object) -> None:
"""A max_chars-truncated numbered read must not mark the unseen tail."""
del workspace
_ = call_sync(write_file, "t.txt", "".join(f"line {i}\n" for i in range(1, 101)))
# Displayed complete lines are editable...
_ = reset_lineno_tracker()
_ = call_sync(read_file, "t.txt", with_lineno=True, max_chars=80)
out = call_sync(edit_lineno, "t.txt", 5, 5, "FIVE\n")
assert "replaced lines 5-5" in out
# ...but the truncated tail (never shown) stays unread.
_ = reset_lineno_tracker()
_ = call_sync(read_file, "t.txt", with_lineno=True, max_chars=80)
out = call_sync(edit_lineno, "t.txt", 90, 90, "X\n")
assert "not read" in out and "90" in out
def test_edit_lineno_error_shows_read_window(workspace: object) -> None:
"""The unread error names the read window so the model can re-read precisely."""
del workspace
_ = call_sync(write_file, "j.txt", "a\nb\nc\nd\n")
_ = reset_lineno_tracker()
_ = call_sync(read_file, "j.txt", offset=1, limit=2, with_lineno=True) # lines 2-3
out = call_sync(edit_lineno, "j.txt", 1, 1, "X\n")
assert "not read" in out
assert "read lines 2-3" in out
assert "margin" in out
def test_edit_lineno_requires_all_lines_read(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "r.txt", "a\nb\nc\nd\ne\n")
@@ -37,6 +66,16 @@ def test_edit_lineno_requires_all_lines_read(workspace: object) -> None:
assert "replaced lines 2-3" in out
def test_edit_lineno_append_to_empty_file(workspace: object) -> None:
"""An empty file has nothing to clobber, so appending needs no read."""
del workspace
_ = call_sync(write_file, "empty.txt", "")
_ = reset_lineno_tracker()
out = call_sync(edit_lineno, "empty.txt", 1, 1, "first\n")
assert "appended" in out
assert call_sync(read_file, "empty.txt") == "L1-1\nfirst\n"
def test_edit_lineno_append_requires_last_line_read(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "ap.txt", "a\nb\nc\n")
@@ -92,7 +131,7 @@ def test_edit_lineno_requires_rerun_after_write(workspace: object) -> None:
_ = _read_lineno("f.txt")
_ = call_sync(write_file, "f.txt", "changed\n")
out = call_sync(edit_lineno, "f.txt", 1, 1, "x\n")
assert "not read" in out
assert "changed" in out and "re-read" in out
def test_edit_lineno_requires_rerun_after_edit_replace(workspace: object) -> None:
@@ -101,7 +140,7 @@ def test_edit_lineno_requires_rerun_after_edit_replace(workspace: object) -> Non
_ = _read_lineno("g.txt")
_ = call_sync(edit_replace, "g.txt", "two", "TWO")
out = call_sync(edit_lineno, "g.txt", 2, 2, "x\n")
assert "not read" in out
assert "changed" in out and "re-read" in out
def test_edit_lineno_invalidates_after_successful_edit(workspace: object) -> None:
@@ -111,4 +150,19 @@ def test_edit_lineno_invalidates_after_successful_edit(workspace: object) -> Non
_ = call_sync(edit_lineno, "h.txt", 2, 2, "B\n")
# After the edit, line numbers are stale — must re-read before editing again.
out = call_sync(edit_lineno, "h.txt", 2, 2, "C\n")
assert "not read" in out
assert "changed" in out and "re-read" in out
def test_edit_lineno_detects_external_change(workspace: object) -> None:
"""A mutation outside the file tools (run_argv, another process) is caught."""
import os
import time
assert isinstance(workspace, Path)
_ = call_sync(write_file, "i.txt", "one\ntwo\nthree\n")
_ = _read_lineno("i.txt")
# Simulate an external/process-tool mutation: rewrite content + new mtime.
_ = call_sync(write_file, "i.txt", "one\ntwo\nthree\nCHANGED\n")
os.utime(workspace / "i.txt", (time.time() + 1, time.time() + 1))
out = call_sync(edit_lineno, "i.txt", 1, 1, "x\n")
assert "changed since it was read" in out
+18
View File
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, override
import pytest
from plyngent.tools.context import read_only_context
from plyngent.tools.danger import classify_danger
from plyngent.tools.net import fetch, grant_private_host
from plyngent.tools.net.policy import (
@@ -199,6 +200,23 @@ async def test_fetch_get_post_put_delete(workspace: Path, http_server: str) -> N
assert "status=204" in out_del
async def test_fetch_read_only_context_blocks_non_get(workspace: Path, http_server: str) -> None:
"""In a read-only context fetch only allows GET; outside it POST works."""
del workspace
port = int(http_server.rsplit(":", 1)[1])
grant_private_host("127.0.0.1", port)
with read_only_context():
out = await call_async(fetch, f"{http_server}/hello")
assert "status=200" in out # GET is fine in a read-only context
blocked = await call_async(fetch, f"{http_server}/echo", method="POST", body="hi")
assert blocked.startswith("error:")
assert "GET" in blocked
out_post = await call_async(fetch, f"{http_server}/echo", method="POST", body="hi")
assert "POST:hi" in out_post
async def test_fetch_user_agent_passthrough(workspace: Path, http_server: str) -> None:
del workspace
port = int(http_server.rsplit(":", 1)[1])
+1
View File
@@ -74,6 +74,7 @@ def test_edit_replace_first_only(workspace: object) -> None:
assert call_sync(read_file, "t.txt") == "L1-1\nbb aa"
assert "1 of 2" in result or "1 of 2 matches" in result
assert "remain" in result
assert "'aa' → 'bb'" in result # old→new excerpt for self-correction
def test_edit_replace_max_replaces(workspace: object) -> None:
+34
View File
@@ -44,6 +44,40 @@ async def test_get_truncated_file_chains_without_gap(workspace: Path) -> None:
assert total_read == 1000
async def test_get_truncated_numbered_continues_and_marks(workspace: Path) -> None:
"""A numbered read's token resumes the numbered view, gap-free and editable."""
from plyngent.agent import reset_lineno_tracker
from plyngent.tools.file import edit_lineno, read_file
(workspace / "big.txt").write_text("".join(f"line {i}\n" for i in range(1, 101)), encoding="utf-8")
reset_lineno_tracker()
first = await call_async(read_file, "big.txt", with_lineno=True, max_chars=120)
token = _extract_token(first)
assert token is not None
parsed = decode_truncate_token(token)
assert parsed is not None
assert parsed.kind == "file"
assert parsed.numbered is True
# The token points at the raw start of the next line (no skip, no overlap).
body = first.split("\n[Truncated", 1)[0]
shown = body.count("\n")
expected_raw = sum(len(f"line {i}\n") for i in range(1, shown + 1))
assert parsed.offset == expected_raw
# Continuation is numbered and starts right after the displayed lines.
cont = await call_async(get_truncated, token, max_chars=200)
numbered = [ln for ln in cont.splitlines() if "|" in ln and ln.strip().split("|", 1)[0].strip().isdigit()]
assert numbered
first_num = int(numbered[0].split("|", 1)[0].strip())
assert first_num == shown + 1
# Resumed lines are editable via edit_lineno; unseen lines stay unread.
out = await call_async(edit_lineno, "big.txt", first_num, first_num, "EDITED\n")
assert "replaced lines" in out
out2 = await call_async(edit_lineno, "big.txt", 90, 90, "X\n")
assert "not read" in out2
async def test_get_truncated_file_missing(workspace: Path) -> None:
token = encode_truncate_token(TruncateToken(kind="file", location="nope.txt", offset=0, limit=200))
out = await call_async(get_truncated, token, max_chars=200)
+93
View File
@@ -0,0 +1,93 @@
"""MCP tool registration: namespacing, tags, catalog source, purge, handlers."""
from __future__ import annotations
import pytest
from plyngent.agent.tools import ToolTag
from plyngent.runtime.mcp_client import McpTool
from plyngent.tools.catalog import catalog_scope
from plyngent.tools.mcp import mcp_tool_name, purge_mcp_tools, register_mcp_server_tools
class _StubManager:
"""Duck-typed McpManager stand-in (records calls, returns canned text)."""
def __init__(self) -> None:
self.calls: list[tuple[str, str, dict[str, object]]] = []
async def call(self, server: str, tool: str, arguments: dict[str, object]) -> str:
self.calls.append((server, tool, arguments))
return f"{server}:{tool}:{arguments.get('text', '')}"
def _tools() -> list[McpTool]:
return [
McpTool(
name="echo",
description="Echo text.",
input_schema={"type": "object", "properties": {"text": {"type": "string"}}},
),
McpTool(name="", description="skipped: empty name", input_schema={}),
]
def test_mcp_tool_name_sanitizes_and_namespaces() -> None:
assert mcp_tool_name("my server", "a/b c") == "mcp__my_server__a_b_c"
assert mcp_tool_name("x", "y").startswith("mcp__")
def test_register_names_tags_source_and_purge() -> None:
manager = _StubManager()
with catalog_scope(empty=True):
names = register_mcp_server_tools("docs", _tools(), manager=manager) # type: ignore[arg-type]
assert names == ["mcp__docs__echo"]
from plyngent.tools.catalog import get_catalog
entry = get_catalog().get("mcp__docs__echo")
assert entry is not None
assert entry.source.kind == "mcp"
assert entry.source.plugin_id == "docs"
assert str(entry.source) == "mcp:docs"
definition = entry.definition
assert definition.tags == ToolTag.LOCAL
assert definition.description.startswith("[mcp:docs]")
assert definition.parameters["type"] == "object"
assert "text" in definition.parameters["properties"]
assert purge_mcp_tools() == 1
assert get_catalog().get("mcp__docs__echo") is None
assert purge_mcp_tools() == 0
def test_register_read_only_flag() -> None:
manager = _StubManager()
with catalog_scope(empty=True):
register_mcp_server_tools("docs", _tools()[:1], manager=manager, read_only=True) # type: ignore[arg-type]
from plyngent.tools.catalog import get_catalog
entry = get_catalog().get("mcp__docs__echo")
assert entry is not None
assert entry.definition.tags == ToolTag.LOCAL | ToolTag.READ_ONLY
def test_duplicate_registration_collides() -> None:
manager = _StubManager()
with catalog_scope(empty=True):
register_mcp_server_tools("docs", _tools()[:1], manager=manager) # type: ignore[arg-type]
with pytest.raises(ValueError, match="collision"):
register_mcp_server_tools("docs", _tools()[:1], manager=manager) # type: ignore[arg-type]
async def test_handler_proxies_to_manager_call() -> None:
manager = _StubManager()
with catalog_scope(empty=True):
register_mcp_server_tools("docs", _tools()[:1], manager=manager) # type: ignore[arg-type]
from plyngent.tools.catalog import get_catalog
entry = get_catalog().get("mcp__docs__echo")
assert entry is not None
result = await entry.definition.handler(text="hi") # type: ignore[union-attr]
assert result == "docs:echo:hi"
assert manager.calls == [("docs", "echo", {"text": "hi"})]
+53
View File
@@ -67,7 +67,60 @@ def test_tree_origin_subdir(workspace: object) -> None:
assert isinstance(workspace, Path)
_build_sample(workspace)
out = call_sync(tree, "src")
assert out.startswith("- ")
assert "- nested/" in out
assert "main.py" in out
assert "deep.txt" in out
def test_tree_default_is_markdown(workspace: object) -> None:
assert isinstance(workspace, Path)
_build_sample(workspace)
out = call_sync(tree, ".")
assert out.startswith("- ")
assert "- src/" in out
assert "├──" not in out
def test_tree_markdown_format(workspace: object) -> None:
assert isinstance(workspace, Path)
_build_sample(workspace)
out = call_sync(tree, ".", format="markdown", max_depth=3, max_entries=10)
assert "- src/" in out
# nested bullets are indented by 2 spaces per level
assert " - main.py" in out
assert " - nested/" in out
assert " - deep.txt" in out
assert "more entries not shown" in out
def test_tree_flat_format(workspace: object) -> None:
assert isinstance(workspace, Path)
_build_sample(workspace)
out = call_sync(tree, ".", format="flat", max_entries=200)
assert "src/" in out # dirs keep a trailing slash
assert "src/main.py" in out
assert "src/nested/deep.txt" in out
assert "a.txt" in out
assert "- " not in out
assert "├──" not in out
def test_tree_flat_total_cap(workspace: object) -> None:
assert isinstance(workspace, Path)
_build_sample(workspace)
out = call_sync(tree, "pkg", format="flat", max_depth=2, max_entries=10)
paths = [line for line in out.splitlines() if not line.startswith("…")]
assert len(paths) == 10
assert "more paths not shown" in out
def test_tree_decorated_format(workspace: object) -> None:
assert isinstance(workspace, Path)
_build_sample(workspace)
out = call_sync(tree, "src", format="decorated")
assert out.startswith("src/")
assert "├──" in out
assert "main.py" in out
assert "deep.txt" in out
+88
View File
@@ -3,13 +3,18 @@ from __future__ import annotations
import pytest
from plyngent.tools import (
AccessMode,
WorkspaceError,
active_workspace_policy,
check_command_allowed,
get_workspace_root,
mode_covers,
parse_access_mode,
resolve_path,
set_command_denylist,
set_path_denylist,
)
from plyngent.tools.context import SessionState, bind_session
def test_resolve_relative_and_absolute(workspace: object) -> None:
@@ -27,6 +32,89 @@ def test_escape_rejected(workspace: object) -> None:
_ = resolve_path("../outside")
def test_escape_error_hints_request_tool(workspace: object) -> None:
del workspace
with pytest.raises(WorkspaceError, match="request_directory_access"):
_ = resolve_path("../outside")
def test_access_mode_parse_and_covers() -> None:
assert parse_access_mode(" Read ") is AccessMode.READ
assert parse_access_mode("EXEC") is AccessMode.EXEC
assert parse_access_mode("bogus") is None
assert mode_covers(AccessMode.EXEC, AccessMode.WRITE)
assert mode_covers(AccessMode.WRITE, AccessMode.WRITE)
assert not mode_covers(AccessMode.READ, AccessMode.WRITE)
def test_config_grant_modes(workspace: object, tmp_path_factory: pytest.TempPathFactory) -> None:
del workspace
outside = tmp_path_factory.mktemp("granted")
target = outside / "data.txt"
_ = target.write_text("x", encoding="utf-8")
policy = active_workspace_policy()
policy.config_allow[outside] = AccessMode.READ
try:
assert resolve_path(str(target), required=AccessMode.READ) == target
with pytest.raises(WorkspaceError, match="escapes"):
_ = resolve_path(str(target), required=AccessMode.WRITE)
policy.config_allow[outside] = AccessMode.WRITE
assert resolve_path(str(target), required=AccessMode.WRITE) == target
with pytest.raises(WorkspaceError, match="escapes"):
_ = resolve_path(str(target), required=AccessMode.EXEC)
finally:
policy.config_allow.clear()
def test_session_grant_covers_subtree(workspace: object, tmp_path_factory: pytest.TempPathFactory) -> None:
del workspace
outside = tmp_path_factory.mktemp("session")
nested = outside / "a" / "b"
nested.mkdir(parents=True)
with bind_session(SessionState(access_grants={outside: AccessMode.EXEC})):
assert resolve_path(str(nested), required=AccessMode.EXEC) == nested
with pytest.raises(WorkspaceError, match="escapes"):
_ = resolve_path(str(nested), required=AccessMode.READ)
def test_file_grant_is_exact_path(workspace: object, tmp_path_factory: pytest.TempPathFactory) -> None:
del workspace
outside = tmp_path_factory.mktemp("file-grant")
granted = outside / "one.txt"
sibling = outside / "two.txt"
_ = granted.write_text("1", encoding="utf-8")
_ = sibling.write_text("2", encoding="utf-8")
policy = active_workspace_policy()
policy.config_allow[granted] = AccessMode.READ
try:
assert resolve_path(str(granted), required=AccessMode.READ) == granted
with pytest.raises(WorkspaceError, match="escapes"):
_ = resolve_path(str(sibling), required=AccessMode.READ)
# A file grant does not cover its parent directory.
with pytest.raises(WorkspaceError, match="escapes"):
_ = resolve_path(str(outside), required=AccessMode.READ)
finally:
policy.config_allow.clear()
def test_denylist_wins_over_grant(workspace: object, tmp_path_factory: pytest.TempPathFactory) -> None:
del workspace
outside = tmp_path_factory.mktemp("denied")
secret = outside / "secret"
secret.mkdir()
key = secret / "key"
_ = key.write_text("k", encoding="utf-8")
policy = active_workspace_policy()
policy.config_allow[outside] = AccessMode.WRITE
set_path_denylist(["/secret/"])
try:
with pytest.raises(WorkspaceError, match="matched '/secret/'"):
_ = resolve_path(str(key), required=AccessMode.READ)
finally:
set_path_denylist(None)
policy.config_allow.clear()
def test_path_denylist(workspace: object) -> None:
from pathlib import Path