25 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
58 changed files with 3315 additions and 281 deletions
+3
View File
@@ -214,3 +214,6 @@ __marimo__/
# Streamlit # Streamlit
.streamlit/secrets.toml .streamlit/secrets.toml
# codegraph (local code index)
.codegraph/
+14 -10
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. 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/`) ### 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/`) ### 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`. `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/`) ### 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/`) ### 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. - **`/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`). - 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`. - 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/`) ### Tools (`tools/`)
Module-level `@tool` handlers. Call `set_workspace_root()` before use. 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. - **`workspace`**: path resolve under primary root **or** temporary allowlist roots **or** a directory-access grant (mode-aware: `read` < `write` < `exec`, default required `write`; a file grant matches only that file); path substring denylist (wins over grants); command basename denylist (CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); escape error hints `request_directory_access`.
- **`access`** (`tools/access.py`): `request_directory_access` (LOCAL|INSTANCE|SESSION) asks the host hook for out-of-workspace access; grants are one `path → mode` pair in one of three stores — `WorkspacePolicy.config_allow` (static `[agent].allow_paths`), `SessionState.access_grants` (human-approved, persisted on the session row), `WorkspacePolicy.yolo_allow` (`--yes`/`/yolo`, process only). `AccessDecision(mode, persist)` from the hook; denylisted paths can never be granted; cap `MAX_ACCESS_GRANTS`. Not a sandbox: `exec` grants (and any command) reach the whole FS.
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range), `write_file`, `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path`, `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume any truncated result via its `truncate_token=...`). - **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range), `write_file`, `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `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). - **`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. - 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). - 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. - **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems.
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core); `wait` (line prompt with timeout; Enter disturbs). - **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core); `wait` (line prompt with timeout; Enter disturbs).
- **`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`). - **`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. - **`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. `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. - **`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`) ### Prompting (`prompting.py`)
@@ -104,8 +108,8 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`. 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`. - **`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. - 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. - `/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**. - 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`. - 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`.
@@ -136,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`) - 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` - 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 - 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). Phase G complete for single-user CLI polish. Next roadmap work is Phase H or optional F (cost/tokenizer).
+17 -3
View File
@@ -147,6 +147,13 @@ access_key_or_token = "sk-..."
# tool_directives = '''### Workspace ...''' # tool_directives = '''### Workspace ...'''
confirm_destructive = true confirm_destructive = true
max_context_tokens = 200000 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. # Optional plugins (entry-point names); default load none. See doc/plugins.md.
# [plugins] # [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). 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: Supported provider presets today:
| Preset | API used by the agent | Notes | | Preset | API used by the agent | Notes |
@@ -209,10 +218,13 @@ plyngent chat --session 3
| `--stream` / `--no-stream` | Streaming deltas (default on) | | `--stream` / `--no-stream` | Streaming deltas (default on) |
| `--quiet` | Less status on stderr | | `--quiet` | Less status on stderr |
| `--yes` | YOLO on: skip destructive-tool confirms for this process | | `--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`, … | | `--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). 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) ### One-shot (scripts / CI)
```bash ```bash
@@ -258,6 +270,7 @@ Type `/help` in the REPL for the live list. Common ones:
| `/models` | List config + remote `GET /models` (always re-fetches) | | `/models` | List config + remote `GET /models` (always re-fetches) |
| `/models --persist` | Merge remote catalog into TOML for this provider | | `/models --persist` | Merge remote catalog into TOML for this provider |
| `/todos` | Todo/task stack: list, push, pop, done, clear | | `/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 | | `/config` | Edit `plyngent.toml` ($VISUAL/$EDITOR or system open); reload after blocking editor |
| `/quit` | Leave the REPL | | `/quit` | Leave the REPL |
@@ -267,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). - **Workspace** = root for file/process/VCS tools (default cwd).
- **Session** = SQLite chat bound to a workspace path. - **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. - Resuming a session from another directory prompts: keep session workspace, rebind to current, or abort.
## Tools (when enabled) ## Tools (when enabled)
Default registry: file ops (including `tree` with a markdown bullet-list default, `flat` paths or classic `decorated` on demand; default noise-dir skips), `run_argv` / `run_argv_batch` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), HTTP `fetch` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), `wait` (line prompt with timeout; Enter disturbs), `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: 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). - 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). - 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. - PTY sessions: caps, idle TTL, output budget; master FD is non-inheritable; sessions closed on chat exit.
@@ -300,7 +314,7 @@ plyngent --log-level INFO chat ...
## Architecture (short) ## 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 - **`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 - **`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: - agent: Kind-based tool loop, streaming, usage, compact; bridges:
``responses_bridge``/``responses_dispatch``, ``messages_bridge``/``messages_dispatch``; ``responses_bridge``/``responses_dispatch``, ``messages_bridge``/``messages_dispatch``;
``@tool`` / tags / registry. History stays chat-completions-shaped. ``@tool`` / tags / registry. History stays chat-completions-shaped.
- tools: Workspace file/process/VCS/chat/todo/net tools; catalog; plugins; - tools: Workspace file/process/VCS/chat/todo/net tools + directory-access
instance/session context and views. grants (`access`); catalog; plugins; instance/session context and views.
- prompting: Shared ask/choose/form for CLI and tools. - prompting: Shared ask/choose/form for CLI and tools.
- cli: Click entry, slash registry, REPL, one-shot chat. - cli: Click entry, slash registry, REPL, one-shot chat.
- web: Web service (Phase H; not implemented). - web: Web service (Phase H; not implemented).
+26
View File
@@ -26,6 +26,22 @@
# [networking] # [networking]
# ssrf_assume_public_cidrs = ["198.18.0.0/15"] # 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] [agent]
# Persona (omit → built-in coding-agent line). tool_directives is the tool playbook. # Persona (omit → built-in coding-agent line). tool_directives is the tool playbook.
# Override with multi-line literals (prefer ''' so nested " is fine): # Override with multi-line literals (prefer ''' so nested " is fine):
@@ -42,8 +58,18 @@ max_tool_result_chars = 32000
parallel_tools = true parallel_tools = true
confirm_destructive = true confirm_destructive = true
path_denylist = ["/secrets/", ".ssh/", ".gnupg/"] 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). # Soft context budget in estimated tokens (~4 chars/token when API usage missing).
max_context_tokens = 200000 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: # How open-todo nags are injected into model context:
# developer (default) | user | synthetic_tool | none # developer (default) | user | synthetic_tool | none
# (legacy system is treated as developer) # (legacy system is treated as developer)
Generated
+201 -151
View File
@@ -4,7 +4,7 @@
[metadata] [metadata]
groups = ["default", "dev"] groups = ["default", "dev"]
strategy = ["inherit_metadata"] strategy = ["inherit_metadata"]
lock_version = "4.5.0" lock_version = "4.5.1"
content_hash = "sha256:94dc16bf6ed12e2fcd7a109b500e9b2d7e39f224b60472d64ecb1d3436ddbb82" content_hash = "sha256:94dc16bf6ed12e2fcd7a109b500e9b2d7e39f224b60472d64ecb1d3436ddbb82"
[[metadata.targets]] [[metadata.targets]]
@@ -52,53 +52,109 @@ files = [
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.9" version = "3.5.1"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
groups = ["default"] groups = ["default"]
files = [ files = [
{file = "charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380"}, {file = "charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8"},
{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.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102"},
{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.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5"},
{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.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3"},
{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.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81"}, {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.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917"}, {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.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41"}, {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.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1"}, {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.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf"}, {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.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48"}, {file = "charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506"},
{file = "charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b"}, {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5"},
{file = "charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519"}, {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198"}, {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5"},
{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.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3"},
{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.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3"},
{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.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36"},
{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.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d"}, {file = "charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e"}, {file = "charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c"}, {file = "charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2"}, {file = "charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534"}, {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.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226"}, {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.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177"}, {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.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501"}, {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.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5"}, {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.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b"}, {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]] [[package]]
name = "click" name = "click"
version = "8.4.2" version = "8.5.0"
requires_python = ">=3.10" requires_python = ">=3.10"
summary = "Composable command line interface toolkit" summary = "Composable command line interface toolkit"
groups = ["default"] groups = ["default"]
dependencies = [
"colorama; platform_system == \"Windows\"",
]
files = [ files = [
{file = "click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76"}, {file = "click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360"},
{file = "click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6"}, {file = "click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34"},
] ]
[[package]] [[package]]
@@ -106,8 +162,8 @@ name = "colorama"
version = "0.4.6" version = "0.4.6"
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
summary = "Cross-platform colored terminal text." summary = "Cross-platform colored terminal text."
groups = ["default", "dev"] groups = ["dev"]
marker = "platform_system == \"Windows\" or sys_platform == \"win32\"" marker = "sys_platform == \"win32\""
files = [ files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
@@ -186,43 +242,43 @@ files = [
[[package]] [[package]]
name = "jh2" name = "jh2"
version = "5.0.13" version = "5.0.14"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "HTTP/2 State-Machine based protocol implementation" summary = "HTTP/2 State-Machine based protocol implementation"
groups = ["default"] groups = ["default"]
files = [ 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.14-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a1ee369128cee3a78be7ade531e7229480c22b20b2371671cfe566f34b47e079"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75d0b2bffb2e260a0f3578a11a4999a713c94d1bd8de7cfe692c840e20ae83cf"}, {file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cfbad7863fcb8dc66dbc9da3e9ccfe11f8944d043618d486fdfd12b1ab54f6a"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f681e197bf6552df3bb7a3abfd676e81316b69e36e7263e72383477174032ca2"}, {file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9087585bd1f8c11fbade60164b9e79f42323784b22be245ceaeca758422e6bc5"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bde7e7ff0f17e87aca9ef23f66b80540a2de0ff8e2c26af55eb2e288a2a5f74f"}, {file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ccbfa14b306d5c5a4a68f3fe9823d686e46406c7dba5babcccb198fd5ae53a11"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f8d364d53cc0854d910f2305726a8254acb527f13fa933dc213a591e21e7ffa"}, {file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5480d38a0eeb43f2bce716aaddee2b89fe30e24b9ac5b7ecfb8cbc7fb822c257"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b572daaafbd867bb5c5f324c918704ddd6d76af1cc7c2be483c3649b76ce7940"}, {file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:348824676428d44d6ddceadb2c1d0885cc351ebe0378eadfa74e382c5359abbe"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0772ad66f346038fe05cb2928866ff1b21bcaa9ec3f14709fa1d419ed1dc287"}, {file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e09fb3bb222ce3cce63bcfab99cb48cbfdc44f56e81369bb1f920ef02c1105f"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b1d8313b13acfdef19c5ff793fdf397bae3d8d75181f6eee62604f13e4ec20a8"}, {file = "jh2-5.0.14-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:20f9b15a367961dbbedeb0f40380668141d3543f427246456f337a6e9fbb5ab2"},
{file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:99aedd20daacb32f377152c9103d8d0271f32d1932bd39a5ed2cd2eef97f7e07"}, {file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3f60ba14d122c0c72b0da283c69e1c0e717fe18c32b8fbd13202954d5d5c7f4b"},
{file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:53b1df29ffe966cde1d2f0085b6acfcd237504627d406a51717ef3849ec88831"}, {file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:9e168d952ac659837c758e206efc7aef2a8b4eb57389691a2675f9059bdbb7d7"},
{file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:0ee6a5dd24a1dc0772a89b3498ed20f60167fbc633404b1e02895f04ae66c47c"}, {file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:dedaf4cc07aee718c4380fcb0bea231f205be6db45a0855454b1d467a0c86837"},
{file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:521f871864880cabe361060f070516a56a54abc3e96f07bee9ba8b061eada891"}, {file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:9fc37f6fbeaba1fd5dd686594655448e2c98fbbb87b2a387faf5a331b2bc85b6"},
{file = "jh2-5.0.13-cp314-cp314t-win32.whl", hash = "sha256:5909e2273f8b6fcea93c95564aa0f366ff9261a544a7a5d640ea95341321cc37"}, {file = "jh2-5.0.14-cp314-cp314t-win32.whl", hash = "sha256:8b4d38bd4bfb3cd2fd0eec62e95e14efd3c97b50535216acbc30afea6b5963e1"},
{file = "jh2-5.0.13-cp314-cp314t-win_amd64.whl", hash = "sha256:2cc4923ac6f57753e0cb4aef054a04f5d5c744b274dabf863f695fa3c2fd75f9"}, {file = "jh2-5.0.14-cp314-cp314t-win_amd64.whl", hash = "sha256:56c3e595254d253657a640bb1f92711836676a654a64484f6b1f1112ec52e68a"},
{file = "jh2-5.0.13-cp314-cp314t-win_arm64.whl", hash = "sha256:d3cf203de31371289d4c1e6b4d30c9f6de95b2bd2b300fe5468ff946299d0e48"}, {file = "jh2-5.0.14-cp314-cp314t-win_arm64.whl", hash = "sha256:391a5357ac82a27d72579ccb9bcb281fd58da0c76e702158416ecabaa65fc3e2"},
{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.14-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:68a23b2b8c1750b8bd208d7e5be0fce20f96aca795f57c56e525a36470773625"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f810dd02b4a9647eacbe4eab617111c0e3ffc68e923e3ff172948db601d2ba1e"}, {file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0c2a4501c2a8ae3db2228c9138dc4a4b047750ffab018c29fbf58de59334bd8"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29c23f3937f257beb5cd5b17f8727404f1267ff25d26e54c0a35d5defd0c895e"}, {file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c32cc7136c54a63fc04abbdb656cd37dcd6b8462b946cd072ebdfbcf9350be45"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c697b250a935030a128b45b41f507a4d33a082d8b8e12311c7e73fbb3970f057"}, {file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c9f39c49b4b02b82202fe9b983f22f1f2aa685ae96ee5800a9b4cad511c07b68"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47d3203ed6fec39df4922764817979fad3a13d22cf750adc9cf9dc2b3399eacf"}, {file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20517ff67db8ac5c3af0bafffe6a639721d8be32f7a9a7c2b25f0ad7f74d2c85"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5146468a76bbe8f4035f23a78813c5c433c70c0ca44eb0ba5558627a9e644b8"}, {file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4df556fe49b596774af0f49f78eff1e143448f8367609d3e6d2a50533ada3855"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d3d338284fbfbe173243c3cc62e510c6fa0d6d023e67da2292bd7578c734e3"}, {file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3e92cd9d01351d14b15c826120bfb41f5834bb0a8860cbaa1779f557d3e1288"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:641f65f5414b8990251bd7609513a696831558f69d2ca77d65dc3f7087f31267"}, {file = "jh2-5.0.14-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a6eeb6cff0f5fcd529d7e0afc75eb81ad5befd5b6b893c20f188fb7f467a0320"},
{file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:cf3dc6f7c14340241260bf63e5d6006b2ee7db45dafcb39c16fdf164ea0240a0"}, {file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:50ef70513db51c8a078e09b0bd824b0a9e3ffd92ee20122c4fa128d3a33b3625"},
{file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:fb8e37ecf37382c02af83da521df5a77561441bc4cbb93b00627e015b7bbf349"}, {file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:52d105593e3af3384119f398682ed29fcbf79c9e87d637f8f49baaeb10adc0d4"},
{file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:296ee44dfad08ccc3857daf433dfa4a555d5b45f2df39503ccdd90ff10b32943"}, {file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:bda792840b62a41bf24049849a38b9355096f1a03a82cc7bc5309e6e85789b1c"},
{file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1a75eed545420a31ea356e8046b8d86fcf694234dd1035191734a03720fb8a44"}, {file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:fddbfe1dd7c55160431975863b415b124ba408c20a8dab6ff3f2bf1eb18c3f8e"},
{file = "jh2-5.0.13-cp37-abi3-win32.whl", hash = "sha256:4812710810010c428db04e9290a13117f7b7c5a3a663b46a0586fe101cf25c9d"}, {file = "jh2-5.0.14-cp37-abi3-win32.whl", hash = "sha256:c41cfd834a360e3eb7b1e3d18b9d1debb16e001e08177d422f65293bd1e586aa"},
{file = "jh2-5.0.13-cp37-abi3-win_amd64.whl", hash = "sha256:5d61bc0a62b4eee11039bf29d4bdbb26efcff760dfb9d8e0739740028aa52b0f"}, {file = "jh2-5.0.14-cp37-abi3-win_amd64.whl", hash = "sha256:1008bd1c05b879e33a9450823a67066716dd430f96a872dc652266c2271d7615"},
{file = "jh2-5.0.13-cp37-abi3-win_arm64.whl", hash = "sha256:3f74dad9036e599eed51576492b00955237b86cf886f96844708a81cd0f5c710"}, {file = "jh2-5.0.14-cp37-abi3-win_arm64.whl", hash = "sha256:b5c82c7045e83e89e4b59d2940c456365bd7ad7e49650ae5a977cacdd4a2354d"},
{file = "jh2-5.0.13-py3-none-any.whl", hash = "sha256:2b311414989da48a155721fe47d0dc3e3b75360efd62d779aaff72d786be91cf"}, {file = "jh2-5.0.14-py3-none-any.whl", hash = "sha256:070895dfaeda94eccbdc45c7c97985d1c6ac339de7c4e329106b6f9450189bda"},
{file = "jh2-5.0.13.tar.gz", hash = "sha256:f8c78cffb3a35c4410513c3eb7989de36028c84277c04f07c97909dd94c23a75"}, {file = "jh2-5.0.14.tar.gz", hash = "sha256:101708db41998159295c403f891dce9d8055a8dd98450c605e6eb7c34751543e"},
] ]
[[package]] [[package]]
@@ -278,7 +334,7 @@ files = [
[[package]] [[package]]
name = "niquests" name = "niquests"
version = "3.21.0" version = "3.21.1"
requires_python = ">=3.7" 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." summary = "Niquests is a simple, yet elegant, HTTP library. It is a drop-in replacement for Requests, which is under feature freeze."
groups = ["default"] groups = ["default"]
@@ -288,13 +344,13 @@ dependencies = [
"wassima<3,>=1.0.1; sys_platform != \"emscripten\"", "wassima<3,>=1.0.1; sys_platform != \"emscripten\"",
] ]
files = [ files = [
{file = "niquests-3.21.0-py3-none-any.whl", hash = "sha256:dbea441b9e9d5d755e02caa2b57b94cdb97b23d754ead21b80a4ac18ef66805f"}, {file = "niquests-3.21.1-py3-none-any.whl", hash = "sha256:ac1ff8061cb480cdaad07f08b95cf21ee0a68ef21fbc2b6ddf5afff8e0d0072e"},
{file = "niquests-3.21.0.tar.gz", hash = "sha256:5b7d10a05f4c7ed08cede0af74f492ae7a8a5a71291833d029e23365fc3ea80a"}, {file = "niquests-3.21.1.tar.gz", hash = "sha256:8ba6ee8712570ae41dfc59ac2ac7e509dcde660c6cc47078b43be423a2f99e64"},
] ]
[[package]] [[package]]
name = "nodejs-wheel-binaries" name = "nodejs-wheel-binaries"
version = "24.16.0" version = "24.19.0"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "unoffical Node.js package" summary = "unoffical Node.js package"
groups = ["dev"] groups = ["dev"]
@@ -302,15 +358,15 @@ dependencies = [
"typing-extensions; python_version < \"3.8\"", "typing-extensions; python_version < \"3.8\"",
] ]
files = [ files = [
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:d9f8f677dcf30e37ac244f07869726abe043f01eb0f45722b1df31cc2af7093c"}, {file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:e12cbfd69089504e42fb14194ce734a9dcf3eb38c820ca63dd511d36fb964e9c"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:3d0370fe7120ce9697a4f60d40480d2bd8808d9f30131458d5afc0040d4e5a51"}, {file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:1c890adf4b7e6556ccc1ca66c866bb81884b6c9a581dee4e530dc7f78fb9d514"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:85dc92bbb79c851569c5925dcc2a4c915a034efab375f99e4e7e6bbe9cca8342"}, {file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4e029dadfae1295876063c96b236f673487e8c27379fe146c1e2250283520227"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:2f3036292811514ba847b3708492644764f88a833ac425c5f55007014308ddfd"}, {file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4196a947bcc883f2003ab101762d729f3e99b5e86b75bd09151563403e2eceb8"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:db8a8a76ebd2b28ecbfc9ad464baa3707241b9e050a30e2efdf6f60c0f886502"}, {file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:352e048ab4dd35e7de5f338d1cc4fcbf77a0e93da30bf7336a8217ee246b31d7"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f1a3d8f7b4491cbbd023ba3fc4e901fcca2d9fb80d57f24ba3890de8b1dbac03"}, {file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:28d078b2ced9e2069516e652dc4b1380e7a1a7f2d3934eccd1611586d283ba4c"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-win_amd64.whl", hash = "sha256:bb136be9944f0662dcf1120f45193a6b75b13fac378971a95cc42c9f879a81aa"}, {file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-win_amd64.whl", hash = "sha256:67e3abeb9c3830cae8c8487ae8a2af7cc27dfa75af06145cee5ca7d1857c81bd"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-win_arm64.whl", hash = "sha256:8308940b5edd0a50dc5267ea36ba21c9f668e83fe0d9f293937174d3a7e31c36"}, {file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-win_arm64.whl", hash = "sha256:d9074c665ea68b04e183d82482c86dc907d3a9bd15eb6cf85542cb785266bb36"},
{file = "nodejs_wheel_binaries-24.16.0.tar.gz", hash = "sha256:c973cb69dc5fd16e6f6dc6e579e2c3d5534e2a1f57619dddf5ba070efa7dde37"}, {file = "nodejs_wheel_binaries-24.19.0.tar.gz", hash = "sha256:db217eef8cab8551667863379b08db4d9067403f6cbbe87481eb40edceb8aa9b"},
] ]
[[package]] [[package]]
@@ -326,13 +382,13 @@ files = [
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.11.2" version = "4.11.7"
requires_python = ">=3.10" requires_python = ">=3.10"
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
groups = ["default"] groups = ["default"]
files = [ files = [
{file = "platformdirs-4.11.2-py3-none-any.whl", hash = "sha256:7f89089b6ea71bda7962953edcf784b2e2d9d285b40ad88be2bb75c6e9d82ab4"}, {file = "platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6"},
{file = "platformdirs-4.11.2.tar.gz", hash = "sha256:3a2ae5fca3520a01ab1be8b45613537f52ddf5b5f6f53d88233892dfbf0cd82d"}, {file = "platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d"},
] ]
[[package]] [[package]]
@@ -348,13 +404,13 @@ files = [
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.20.0" version = "2.21.0"
requires_python = ">=3.9" requires_python = ">=3.9"
summary = "Pygments is a syntax highlighting package written in Python." summary = "Pygments is a syntax highlighting package written in Python."
groups = ["default", "dev"] groups = ["default", "dev"]
files = [ files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, {file = "pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"},
] ]
[[package]] [[package]]
@@ -410,47 +466,47 @@ files = [
[[package]] [[package]]
name = "qh3" name = "qh3"
version = "1.9.4" version = "2.0.3"
requires_python = ">=3.7" 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"] 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\"" 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 = [ 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-2.0.3-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:db98ea31b7486cac5dffc76c15b34c08208b219379aa9d274df21b1d47c7dbcb"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b9d45e544e0a556380bd2a92af180ee98d32bca6cf622f28350ebc000611bdf"}, {file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bb0a8f54275e0ecb722c0a6cebeff73771b31da0dbfaed8e4b72efcbe98e2ab"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43f80d662078c26563a1aac32a4df22649473d996bf2422e073c8aa55b1c837c"}, {file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e466a285b8f598ea06a296910a817571f132bb3ff3f5a26cdc905ffe6d9d743c"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:551f60d487d849d6f2313dda94e54ebb16b1a29acb93676540ae790a56308c24"}, {file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a266a68f27a86d6d49978e1c6b8e9e5dd514e018a042dafca48cbeb50a29d0d3"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3755d777ff35561bc719f571ebc4b91aaa8a945c210e50b2576bf2cc69cb511f"}, {file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2646d05d20e7171d928d9b478097743d0d2a5b3893f8caddd7984b2825d90daa"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04b64bb18e10d8f482bea1ef43e5da3448e2d5ba78bb0c735da5b9e02d7dfb0e"}, {file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ccac4672539eb2897c3beaa73b626460040adf94c30edbd420db3988bd08e0f1"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b47597daa4ae9195f81a35822217a395d3277f01a393d9c2bb3796a6b458b22c"}, {file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0e57b96af05653ddcc4797fe761918d5d0a25ed90a9176260333f48e7b88f72"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9bb9012bc02c44da6af1b931b6082588519685fad8a1f866c341f749394f53f"}, {file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73845b61987f44c5d55f9606a38b4078cac7e816dadeeaceb35a49ff741d4c8e"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:9c7f3781ec022dd958b5eb0e5cb2e2a54e207a3d8837fb7290bc239912648fd7"}, {file = "qh3-2.0.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:4f05f1b3ac250213db0da6895aeb944ca3cb63cd1a41296f1dddafd8d78a1722"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:633a24ab2db76d64881876c9d0f1b226a47d892bd7d78e61ea2c788ab59570af"}, {file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3be9e2cfc447807c4195c12d9590052ee45cc17ed92d99f44d05a49d657bf8d4"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:f2ff36b84a4ab9b68806af1f6985fa3b47e29695effe766a3e70c21efac8157c"}, {file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:bb83310f908a9d00b781fe95f7acda51025d0cea3a985929e84b0d46333709a2"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:2100697d736dbdfd1d3f0266c9fcadea6230b66ed59e3e7f8c9a9d1a6eec0c65"}, {file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:991ff127a8b0aef9ae07a4155f595158fd005d9cc522d204ec31a5a5f197517c"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:9ce179efaf97f927f01fe158619f5624d8d874c7161b7c497f06897cdde210d5"}, {file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:efbcd87e7c45f51690f6e526ed4956c57e428eb4c5684878987d43ffa14a93b0"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:476ee4fcdbd3454eb0eb4dee5678294f06c08ca8e8b83160e2d0ae3c4ae34603"}, {file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:86f2256aa78604d6733ff12a225d6780df18a9b171e72d45dd30ee62d5ab0568"},
{file = "qh3-1.9.4-cp314-cp314t-win32.whl", hash = "sha256:fe9df8136bf358d45a2757287523abaeb208fcbe2c1e34664562eb2d8f64dbdb"}, {file = "qh3-2.0.3-cp314-cp314t-win32.whl", hash = "sha256:91569e8ee979c84a1972916cc9b3283760dda111fc8f67cce45ccdb4f14b67c5"},
{file = "qh3-1.9.4-cp314-cp314t-win_amd64.whl", hash = "sha256:1fbdf53feb1732faf6c1cb34f2e1cbe4b543054db7b4d19d8752f1162e9370be"}, {file = "qh3-2.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:eb0333f67bee13a3b5f08e91ece87fd0f5e5448e955efb11d7bd2bfacf21bfb5"},
{file = "qh3-1.9.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e03ee1e2157a31c69415c3dbfbfe64e02469499ca46e968d13d8a7b289bf520a"}, {file = "qh3-2.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:8c9f6b22d073948b3042ec0b50dab5581865a30d41517ba6aad8dbc1a4b39a6b"},
{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-2.0.3-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e96296b00eb23ff828d7789070b3d22c7139efbc6bc62fed366c31bad622d174"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a57b3dfe5dd2da2084adea63dba0bf587a4096ebf9207339187c90139d1f460d"}, {file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a09e33277ded5f94f9b73b0c8c2f5b3e044f3c41ed5c830ab61e72962e22d09"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7fa1a3debb5199da3c7f45b0ca6efa55174dfd6229370e774099073143ebe9e9"}, {file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8bbbc57b1cff5acebf736965c072f35023f4d0e5975b3d8cda7b7e07250af1b"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6dc9ff40d3a06f27b47d93417666f03ba6fd6b9585d9711ebbddf84fa4b7c50f"}, {file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06b8ee7434cd0ecab6b916abc63a46f4ad8b3ebbc284da405aeb650882826e91"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:010b132c33719af3df4c1d6f569e52a2a20046c55a31c5532e741019c8c53868"}, {file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ec715af40af199c92eedbd620bbff8ddc514e19f2bbd660e65953752c364105f"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:124045b0c04c05afc038a239d7717019b4c4b9d0cb967040b4331f4d588eff7d"}, {file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bcbdc6a574f83f957e6bf430cda8b98a512870164563b7ef1fdd553420f032a"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a94a1726bf46a451a0295d68fafde28794e83ab6fda7457cbd9309c38151de9"}, {file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5c9ba43b3f68f9ade9553ff23d89455596b9c5ba8179a103f75c8771d355535"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b361e627b6a529ecb231c1d5f3f2abd6d1952b0d7cc02c7718d9657d55dae37"}, {file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51480d90b4ac7e81248fd020a726d4089cf8b881a4b6cbdc3c04c329831a3fc6"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:43b6cfb2240836e3e54bbe61ad1b8fa5716516ff5359131a2421c55676a24f26"}, {file = "qh3-2.0.3-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:235161f4cbd791e18ea865d10fdd52d0ddbcbb807a1849c98360c4e570c06844"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e6db3def610e9c2d2f3602d3e9c8b2156273225f6eedf74a4749d3bc21af8167"}, {file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7abb8ae6c8ae6b49827b26eaffeac0892893450d2022818e1db3ff34d26a835e"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:22d5879dc93d2c7ee14cc5933b12c7780d9c7705dff4553becea152fcb767d99"}, {file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:57f43869ef306877167277c48c1fa7c446fc73fc9355fa1ccae5094507b63d9e"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:2c2b2005fe6c605d61cac05ec2b3c019cc546184fa43444f3d1e3d22ed020ab2"}, {file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:192021606e283a59b47bf1e1cb719087c77869976aea76f26d589a1df8aa0dd5"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:e991b0f20b172fea466d0ffd499a85bf2f26decdeeda537275c72b5f7b26ccfe"}, {file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:f33ca1ac278a8d8ff115bcde76fc4555fa1dbfa147e83dad2ebcba6bc663e6d5"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c13775ed27a88b297f6a1aa05ffb286879453f99e1e53e0af2b22e7ed8f92ebf"}, {file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:74814e3dd91011235905856701037ead68f7a8be641f469fa5e5cdabf7d03a28"},
{file = "qh3-1.9.4-cp37-abi3-win32.whl", hash = "sha256:952abe1be90c0114993886792a8a5ec2211f8391df41227f5b7a5a02ff71bc26"}, {file = "qh3-2.0.3-cp37-abi3-win32.whl", hash = "sha256:20ceb5482ef6f352d9c7351fbde69f94f49d07f068ce9aa4e88ea76fca7eddda"},
{file = "qh3-1.9.4-cp37-abi3-win_amd64.whl", hash = "sha256:5543cb40a48d401f3fa4ec89398e3a213634f195716a9d06d70417c05edbbb32"}, {file = "qh3-2.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:94ba6ddb786742ff93904db09cb4b6fbcaf7de115161c05918024cd52b2c8a61"},
{file = "qh3-1.9.4-cp37-abi3-win_arm64.whl", hash = "sha256:d0bd73b3e73f4f80d2430d410b532222ecf57e9d4fe8600b7683c775cbff9479"}, {file = "qh3-2.0.3-cp37-abi3-win_arm64.whl", hash = "sha256:0b13789c68983a994e8c12e3ff0fbc02f07c0aaaeec5670b57802f5d3d555e81"},
{file = "qh3-1.9.4.tar.gz", hash = "sha256:bd2ea9baf19656c544a48a56a195f2ac257cd973b566f5f2998fa3b7446281a1"}, {file = "qh3-2.0.3.tar.gz", hash = "sha256:546ab2d3193e37e98dfefe5834484e07d0bf531708f21e1a4e227a51510b648f"},
] ]
[[package]] [[package]]
@@ -497,7 +553,7 @@ files = [
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.51" version = "2.0.52"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "Database Abstraction Library" summary = "Database Abstraction Library"
groups = ["default"] groups = ["default"]
@@ -507,22 +563,16 @@ dependencies = [
"typing-extensions>=4.6.0", "typing-extensions>=4.6.0",
] ]
files = [ files = [
{file = "sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491"}, {file = "sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9"},
{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.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e"},
{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.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437"},
{file = "sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e"}, {file = "sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8"},
{file = "sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d"}, {file = "sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd"},
{file = "sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8"}, {file = "sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2"},
{file = "sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499"}, {file = "sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de"}, {file = "sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582"},
{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.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89"},
{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.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97"},
{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"},
] ]
[[package]] [[package]]
@@ -549,28 +599,28 @@ files = [
[[package]] [[package]]
name = "urllib3-future" name = "urllib3-future"
version = "2.24.900" version = "2.24.907"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces" summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces"
groups = ["default"] groups = ["default"]
dependencies = [ dependencies = [
"h11<1.0.0,>=0.11.0", "h11<1.0.0,>=0.11.0",
"jh2<6.0.0,>=5.0.3", "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 = [ files = [
{file = "urllib3_future-2.24.900-py3-none-any.whl", hash = "sha256:a4fe06b59b0c3ce2fffa821c3de72eaf59b1e61619becc255dee18919ee25260"}, {file = "urllib3_future-2.24.907-py3-none-any.whl", hash = "sha256:6f9007bd9c6d3ea13d8fe70e5d4b87a6d15638a849bd2227ab5cbdfbe5d1e842"},
{file = "urllib3_future-2.24.900.tar.gz", hash = "sha256:05c2e9d09293ab29a154fed8f5b60644c7cdaffe4ae2587a07b1c7f00f23a7fb"}, {file = "urllib3_future-2.24.907.tar.gz", hash = "sha256:6565b3cc160a950821953079a6ccd4daa8fd60071a13c9a251576648929f3aa7"},
] ]
[[package]] [[package]]
name = "wassima" name = "wassima"
version = "2.1.3" version = "2.1.4"
requires_python = ">=3.7" requires_python = ">=3.7"
summary = "Access your OS root certificates with utmost ease" summary = "Access your OS root certificates with utmost ease"
groups = ["default"] groups = ["default"]
marker = "sys_platform != \"emscripten\"" marker = "sys_platform != \"emscripten\""
files = [ files = [
{file = "wassima-2.1.3-py3-none-any.whl", hash = "sha256:f8251c23720dd092117a554b3012756caee3feed6d0122c2458a2aca5d7d92c5"}, {file = "wassima-2.1.4-py3-none-any.whl", hash = "sha256:26022272d4618aada0e26df2e79dbc0e034be8be9d6d5396512690ffc6a94e7b"},
{file = "wassima-2.1.3.tar.gz", hash = "sha256:fcd6c38be0f909c393da35cb2a993101fcdcff673b8fa8d5da228f73b630d0d0"}, {file = "wassima-2.1.4.tar.gz", hash = "sha256:21bb77253bb8032c05393172c4d2df395f423b7e704538f3b2407f50c032034d"},
] ]
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "plyngent" name = "plyngent"
version = "0.4.1" version = "0.6.0"
description = "LLM chat and agent toolkit" description = "LLM chat and agent toolkit"
authors = [ authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" }, { 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" DEFAULT_WIN_UV_VERSION = "0.11.28"
# typings/ is required for Wine basedpyright: pywinpty's shipped types are incomplete # typings/ is required for Wine basedpyright: pywinpty's shipped types are incomplete
# (e.g. kill(sig) required); pyright default stubPath is ``typings/``. # (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") VIEW_COPIES = ("pyproject.toml", "pdm.lock")
+54 -5
View File
@@ -26,7 +26,7 @@ from plyngent.cli.state import ReplState
from plyngent.config.models import DatabaseConfig from plyngent.config.models import DatabaseConfig
from plyngent.memory import MemoryStore from plyngent.memory import MemoryStore
from plyngent.prompting import NonInteractiveBackend, configure_prompting 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: if TYPE_CHECKING:
from collections.abc import Mapping 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 return text or None
def _setup_hooks(*, interactive: bool) -> None: def _setup_hooks(*, interactive: bool, auto_continue: bool) -> None:
"""Install interactive limit hooks (workspace policy is set on ReplState).""" """Install limit hooks (workspace policy is set on ReplState).
if interactive:
``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() install_cli_limit_hooks()
else: else:
from plyngent.tools.process.pty_session import PtyManager from plyngent.tools.process.pty_session import PtyManager
@@ -134,6 +139,24 @@ def _setup_hooks(*, interactive: bool) -> None:
PtyManager.set_limit_continue_hook(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( async def _bind_session(
state: ReplState, state: ReplState,
*, *,
@@ -203,6 +226,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
prompt_text: str | None, prompt_text: str | None,
stream: bool, stream: bool,
yes: bool, yes: bool,
auto_continue: bool,
quiet: bool, quiet: bool,
) -> int: ) -> int:
oneshot = prompt_text is not None oneshot = prompt_text is not None
@@ -218,7 +242,8 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
if store.recoverable_providers: if store.recoverable_providers:
_warn_recoverable_providers(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. # --yes forces sticky YOLO; else derive from config.confirm_destructive.
from plyngent.cli.state import YoloMode from plyngent.cli.state import YoloMode
@@ -285,6 +310,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
except ProviderNotSupportedError as exc: except ProviderNotSupportedError as exc:
raise click.ClickException(str(exc)) from exc raise click.ClickException(str(exc)) from exc
mcp_manager = await _start_mcp_manager(store)
state = ReplState( state = ReplState(
config=store, config=store,
memory=memory, memory=memory,
@@ -296,7 +322,9 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
max_rounds=max_rounds, max_rounds=max_rounds,
stream_enabled=stream, stream_enabled=stream,
interactive_limits=interactive, interactive_limits=interactive,
auto_continue_limits=auto_effective,
yolo=yolo, yolo=yolo,
mcp_manager=mcp_manager,
) )
# Path denylist and policy confirm live on instance.workspace (no process bag). # 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 ()) 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, prompt_policy_fetch_confirm,
instance=state.instance_state, 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. # Seed cache if we already fetched; else warm in background for Tab.
if remote_ids is not None: if remote_ids is not None:
state.seed_remote_models(remote_ids) 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) set_fetch_policy_confirm_hook(None, instance=state_obj.instance_state)
clear_private_grants(instance=state_obj.instance_state) clear_private_grants(instance=state_obj.instance_state)
clear_ssrf_assume_public_cidrs() 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() await state_obj.instance_state.shutdown()
else: else:
# No ReplState: only PTY class cleanup (temps require instance allowlist). # 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, default=False,
help="Enable YOLO: skip destructive-tool confirms (sticky for this process).", 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( @click.option(
"--quiet", "--quiet",
is_flag=True, is_flag=True,
@@ -465,6 +512,7 @@ def chat_cmd(
prompt: str | None, prompt: str | None,
stream: bool, # noqa: FBT001 stream: bool, # noqa: FBT001
yes: bool, # noqa: FBT001 yes: bool, # noqa: FBT001
auto_continue: bool, # noqa: FBT001
quiet: bool, # noqa: FBT001 quiet: bool, # noqa: FBT001
) -> None: ) -> None:
"""Interactive chat REPL, or one-shot with ``-p`` / stdin. """Interactive chat REPL, or one-shot with ``-p`` / stdin.
@@ -505,6 +553,7 @@ def chat_cmd(
prompt_text=prompt_text, prompt_text=prompt_text,
stream=stream, stream=stream,
yes=yes, yes=yes,
auto_continue=auto_continue,
quiet=quiet, quiet=quiet,
) )
) )
+4 -1
View File
@@ -154,7 +154,7 @@ def _pretty_line(prefix: str, *segments: tuple[str, str | None]) -> str:
def _read_file_pretty(args_json: str, result: str) -> str: def _read_file_pretty(args_json: str, result: str) -> str:
"""One-line summary for a ``read_file`` call: ``* Read 'path' (done)``.""" """One-line summary for a ``read_file`` call: ``* Read 'path' L1-4 (done)``."""
path = _json_str_arg(args_json, "path") or "?" path = _json_str_arg(args_json, "path") or "?"
if result.startswith("error: file not found"): if result.startswith("error: file not found"):
return _pretty_line(f"* Read '{path}' ", ("(file not found)", "red")) return _pretty_line(f"* Read '{path}' ", ("(file not found)", "red"))
@@ -162,6 +162,9 @@ def _read_file_pretty(args_json: str, result: str) -> str:
return _pretty_line(f"* Read '{path}' ", ("(not a file)", "red")) return _pretty_line(f"* Read '{path}' ", ("(not a file)", "red"))
if result.startswith("error:"): if result.startswith("error:"):
return _pretty_line(f"* Read '{path}' ", ("(error)", "red")) return _pretty_line(f"* Read '{path}' ", ("(error)", "red"))
first = result.splitlines()[0] if result.splitlines() else ""
if first.startswith("L") and "-" in first:
return _pretty_line(f"* Read '{path}' ", (f"{first} ", "dim"), ("(done)", "green"))
return _pretty_line(f"* Read '{path}' ", ("(done)", "green")) return _pretty_line(f"* Read '{path}' ", ("(done)", "green"))
+124 -8
View File
@@ -4,6 +4,7 @@ import contextlib
import selectors import selectors
import shutil import shutil
import sys import sys
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING, Literal
from plyngent.cli.interrupt import pause_task_cancel_for_prompt from plyngent.cli.interrupt import pause_task_cancel_for_prompt
@@ -18,16 +19,56 @@ from plyngent.prompting import (
confirm, confirm,
get_prompt_backend, get_prompt_backend,
) )
from plyngent.tools.access import AccessDecision
from plyngent.tools.process.pty_session import PtyManager from plyngent.tools.process.pty_session import PtyManager
from plyngent.tools.workspace import AccessMode, parse_access_mode
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from pathlib import Path
type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"] type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"]
_BOX_MIN_WIDTH = 40 _BOX_MIN_WIDTH = 40
_BOX_MAX_WIDTH = 100 _BOX_MAX_WIDTH = 100
_BOX_PAD = 2 # spaces inside left/right borders _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: def _terminal_width() -> int:
try: try:
@@ -104,10 +145,33 @@ def _echo_continue_limit(reason: str) -> None:
backend.echo() 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: def _prompt_continue_limit_sync(reason: str) -> bool:
try: try:
_echo_continue_limit(reason) return _decide_continue_limit(reason)
return confirm("Raise limit and continue?", default=True)
except NonInteractiveError: except NonInteractiveError:
return False return False
@@ -119,15 +183,11 @@ def prompt_continue_limit(reason: str) -> bool:
async def prompt_continue_limit_async(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: try:
from plyngent.prompting import run_prompt_async from plyngent.prompting import run_prompt_async
def _run() -> bool: return await run_prompt_async(lambda: _decide_continue_limit(reason))
_echo_continue_limit(reason)
return confirm("Raise limit and continue?", default=True)
return await run_prompt_async(_run)
except NonInteractiveError: except NonInteractiveError:
return False return False
@@ -287,6 +347,62 @@ def prompt_policy_fetch_confirm(
return False 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 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.""" """Async confirm: True allow, False deny, str = deny with user comment."""
del args del args
+4 -1
View File
@@ -36,14 +36,17 @@ def _echo_interrupted() -> None:
async def run_repl(state: ReplState) -> None: async def run_repl(state: ReplState) -> None:
"""Interactive chat loop with readline editing, history, and Tab completion.""" """Interactive chat loop with readline editing, history, and Tab completion."""
setup_readline(state) setup_readline(state)
from plyngent.cli.limits import auto_continue_enabled
yolo = state.effective_yolo() yolo = state.effective_yolo()
yolo_part = f" yolo={yolo}" if yolo != "off" else "" yolo_part = f" yolo={yolo}" if yolo != "off" else ""
auto_part = " limit_auto=on" if auto_continue_enabled() else ""
click.echo( click.echo(
f"plyngent chat provider={state.provider_name} model={state.model} " f"plyngent chat provider={state.provider_name} model={state.model} "
f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} " f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} "
f"rounds={state.max_rounds} messages={len(state.agent.messages)} " f"rounds={state.max_rounds} messages={len(state.agent.messages)} "
f"stream={'on' if state.agent.stream else 'off'} " 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.') 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.display import render_events
from plyngent.cli.interrupt import allow_task_cancel, set_sigint_reinstall from plyngent.cli.interrupt import allow_task_cancel, set_sigint_reinstall
from plyngent.cli.limits import reset_auto_continue_turn
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable, Coroutine 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. 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``. 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) max_retries = len(delays)
attempt = 0 attempt = 0
current: Callable[[], AsyncIterator[AgentEvent]] = starter current: Callable[[], AsyncIterator[AgentEvent]] = starter
+130
View File
@@ -521,6 +521,7 @@ def config_cmd(state: ReplState) -> None:
def status_cmd(state: ReplState) -> None: def status_cmd(state: ReplState) -> None:
"""Show session/provider/tools/rounds status.""" """Show session/provider/tools/rounds status."""
from plyngent.agent.budget import estimate_messages_chars from plyngent.agent.budget import estimate_messages_chars
from plyngent.tools.access import access_grant_rows
pending = state.agent.pending_retry_text pending = state.agent.pending_retry_text
pending_disp = "yes" if pending else "no" pending_disp = "yes" if pending else "no"
@@ -555,6 +556,7 @@ def status_cmd(state: ReplState) -> None:
f"usage_last_turn={last_u.format_line(billed=True)} " f"usage_last_turn={last_u.format_line(billed=True)} "
f"rounds={last_rounds}\n" f"rounds={last_rounds}\n"
f"usage_session={session_u.format_line(billed=True)}\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}" f"workspace={state.workspace}"
) )
@@ -1016,11 +1018,17 @@ def btw_cmd(
else: else:
await run_cancellable(side) 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: try:
_await(_run()) _await(_run())
except Exception as exc: # noqa: BLE001 — surface side-turn failures except Exception as exc: # noqa: BLE001 — surface side-turn failures
click.secho(f"error: btw failed: {exc}", fg="red") click.secho(f"error: btw failed: {exc}", fg="red")
return return
finally:
reset_auto_continue_turn()
click.secho("(btw done — main session unchanged)", fg="bright_black") click.secho("(btw done — main session unchanged)", fg="bright_black")
@@ -1147,6 +1155,128 @@ def plugins_slash_cmd(state: ReplState, action: str | None, name: str | None) ->
raise click.UsageError(str(exc)) from exc 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") @slash.command("yolo")
@click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]") @click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]")
@click.pass_obj @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 from plyngent.tools.view import MemoryViewStore, session_data_view
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Sequence from collections.abc import Mapping, Sequence
from plyngent.config.models import Provider from plyngent.config.models import Provider
from plyngent.config.store import ConfigStore from plyngent.config.store import ConfigStore
from plyngent.memory import MemoryStore from plyngent.memory import MemoryStore
from plyngent.memory.database.schema import Session as SessionRow 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"] 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 @dataclass
class ReplState: class ReplState:
"""Mutable REPL session state.""" """Mutable REPL session state."""
@@ -54,6 +69,8 @@ class ReplState:
markdown_enabled: bool = True markdown_enabled: bool = True
# One-shot / scripts: never prompt to raise tool-loop limits. # One-shot / scripts: never prompt to raise tool-loop limits.
interactive_limits: bool = True 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. # Soft destructive-tool confirms: None → derive from config.confirm_destructive.
# off = confirm; on = skip (sticky); once = skip next user turn then off. # off = confirm; on = skip (sticky); once = skip next user turn then off.
yolo: YoloMode | None = None yolo: YoloMode | None = None
@@ -65,6 +82,8 @@ class ReplState:
todo_stack: TodoStack = field(default_factory=TodoStack) todo_stack: TodoStack = field(default_factory=TodoStack)
instance_state: InstanceState = field(default_factory=InstanceState) instance_state: InstanceState = field(default_factory=InstanceState)
session_state: SessionState = field(default_factory=SessionState) 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) _todo_persist_tasks: set[object] = field(default_factory=set, init=False, repr=False)
# Session ids for Tab complete (updated when listing/creating/resuming). # Session ids for Tab complete (updated when listing/creating/resuming).
_session_id_cache: list[int] = field(default_factory=list, init=False, repr=False) _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.workspace = Path(self.workspace).expanduser().resolve()
self.instance_state.workspace_root = self.workspace self.instance_state.workspace_root = self.workspace
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.session_state = self._session_data_for_todo()
self.agent = self._make_agent() self.agent = self._make_agent()
self.sync_display_flags() self.sync_display_flags()
@@ -90,6 +113,12 @@ class ReplState:
set_verbose_tool_results(self.verbose) set_verbose_tool_results(self.verbose)
set_markdown_enabled(self.markdown_enabled) 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: def _workspace_key(self) -> str:
key = normalize_workspace(self.workspace) key = normalize_workspace(self.workspace)
if key is None: if key is None:
@@ -145,11 +174,17 @@ class ReplState:
self._todo_persist_tasks.add(task) self._todo_persist_tasks.add(task)
task.add_done_callback(self._todo_persist_tasks.discard) 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). """Build a SessionState with live todo + MemoryViewStore seed (durable tree).
*grants* defaults to empty (new session / startup). Pass the previous map *grants* defaults to empty (new session / startup). Pass the previous map
only when intentionally preserving soft-confirm trust across rebinds. only when intentionally preserving soft-confirm trust across rebinds.
*access_grants* seeds session directory grants (empty clears them).
""" """
grant_map = dict(grants or {}) grant_map = dict(grants or {})
store = MemoryViewStore({"todo": self.todo_stack.to_raw(), "grants": dict(grant_map)}) store = MemoryViewStore({"todo": self.todo_stack.to_raw(), "grants": dict(grant_map)})
@@ -158,7 +193,9 @@ class ReplState:
data=session_data_view(store=store), data=session_data_view(store=store),
todo=self.todo_stack, todo=self.todo_stack,
on_todo_change=self._todo_on_change, on_todo_change=self._todo_on_change,
on_access_change=self._on_access_change,
grants=grant_map, grants=grant_map,
access_grants=dict(access_grants or {}),
) )
def _bind_todo_tools(self) -> None: def _bind_todo_tools(self) -> None:
@@ -200,6 +237,67 @@ class ReplState:
if hasattr(self, "agent"): if hasattr(self, "agent"):
self.agent.todo_stack = self.todo_stack 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: def _tool_registry(self) -> ToolRegistry | None:
if not self.tools_enabled: if not self.tools_enabled:
return None return None
@@ -215,6 +313,11 @@ class ReplState:
plugins_cfg.enable, plugins_cfg.enable,
disable=plugins_cfg.disable, 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). # Local surface: builtins + allowlisted plugins (import registers into catalog).
tools = catalog.select(surface="local") tools = catalog.select(surface="local")
yolo = self.effective_yolo() != "off" yolo = self.effective_yolo() != "off"
@@ -229,16 +332,30 @@ class ReplState:
session_state=self.session_state, 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: 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 from plyngent.config import compose_agent_system_content
agent_cfg = self.config.agent_config agent_cfg = self.config.agent_config
mcp_text = self._mcp_instructions_text() if agent_cfg.mcp_instructions else ""
system_prompt = compose_agent_system_content( system_prompt = compose_agent_system_content(
agent_cfg.system_prompt, agent_cfg.system_prompt,
agent_cfg.tool_directives, 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 peak = 0
band = 0 band = 0
last_req = None last_req = None
@@ -552,6 +669,7 @@ class ReplState:
return False return False
async def new_session(self, name: str = "chat") -> None: async def new_session(self, name: str = "chat") -> None:
self._reset_limit_auto_continue()
session = await self.memory.create_session( session = await self.memory.create_session(
name=name, name=name,
workspace=self.workspace, workspace=self.workspace,
@@ -590,6 +708,7 @@ class ReplState:
"""Load a session; on workspace mismatch, prompt keep / rebind / abort.""" """Load a session; on workspace mismatch, prompt keep / rebind / abort."""
from plyngent.cli.limits import prompt_workspace_mismatch from plyngent.cli.limits import prompt_workspace_mismatch
self._reset_limit_auto_continue()
row = await self.memory.get_session(session_id) row = await self.memory.get_session(session_id)
if row is None: if row is None:
msg = f"session not found: {session_id}" msg = f"session not found: {session_id}"
@@ -623,9 +742,11 @@ class ReplState:
self.agent = self._make_agent() self.agent = self._make_agent()
await self.agent.load_history() await self.agent.load_history()
await self.load_todo_stack() await self.load_todo_stack()
await self.load_access_grants()
async def resume_latest_or_new(self, name: str = "chat") -> str: async def resume_latest_or_new(self, name: str = "chat") -> str:
"""Resume most recently updated session for this workspace, or create one.""" """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) latest = await self.memory.get_latest_session(workspace=self.workspace)
if latest is None: if latest is None:
await self.new_session(name=name) await self.new_session(name=name)
@@ -638,6 +759,7 @@ class ReplState:
self.agent = self._make_agent() self.agent = self._make_agent()
await self.agent.load_history() await self.agent.load_history()
await self.load_todo_stack() await self.load_todo_stack()
await self.load_access_grants()
_ = await self.memory.touch_session(latest.sid) _ = await self.memory.touch_session(latest.sid)
return "resume" return "resume"
+2
View File
@@ -13,6 +13,8 @@ from .models import DatabaseConfig as DatabaseConfig
from .models import DeepSeekConvention as DeepSeekConvention from .models import DeepSeekConvention as DeepSeekConvention
from .models import DeepseekProvider as DeepseekProvider from .models import DeepseekProvider as DeepseekProvider
from .models import HttpTimeoutConfig as HttpTimeoutConfig 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 ModelConfig as ModelConfig
from .models import NetworkingConfig as NetworkingConfig from .models import NetworkingConfig as NetworkingConfig
from .models import OpenAICompatibleProvider as OpenAICompatibleProvider from .models import OpenAICompatibleProvider as OpenAICompatibleProvider
+59 -11
View File
@@ -10,6 +10,9 @@ from msgspec import Struct, field
# ``https://api.deepseek.com/anthropic``) # ``https://api.deepseek.com/anthropic``)
type DeepSeekConvention = Literal["", "openai", "anthropic", "responses"] 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. # Built-in persona when ``[agent].system_prompt`` is omitted.
# Set ``system_prompt = ""`` to omit the persona block only. # Set ``system_prompt = ""`` to omit the persona block only.
# Override with a multi-line TOML literal (prefer ''' so nested " is fine). # Override with a multi-line TOML literal (prefer ''' so nested " is fine).
@@ -23,6 +26,8 @@ DEFAULT_TOOL_DIRECTIVES = """\
### Workspace ### Workspace
- Explore with `tree`/`listdir`/`glob_paths`/`grep_files`/`read_file` when unsure. - 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. - 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 ### Files
- Prefer file tools over shell. `edit_replace` fails usually means a bad match — \ - 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( def compose_agent_system_content(*parts: str) -> str | None:
system_prompt: str, """Join persona + tool playbook + extra blocks into one system body.
tool_directives: str,
) -> str | None:
"""Join persona + tool playbook into one system body, or ``None`` if both empty.
Non-empty parts are stripped and joined with a blank line. Either field may Returns ``None`` when every part is empty. Non-empty parts are stripped and
be ``""`` to disable that part while keeping the other. 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()] joined = "\n\n".join(part.strip() for part in parts if part and part.strip())
if not parts: return joined or None
return None
return "\n\n".join(parts)
class DatabaseConfig(Struct, omit_defaults=True): class DatabaseConfig(Struct, omit_defaults=True):
@@ -106,10 +107,19 @@ class AgentConfig(Struct, omit_defaults=True):
system_prompt: str = DEFAULT_SYSTEM_PROMPT system_prompt: str = DEFAULT_SYSTEM_PROMPT
# Tool how-to (omit → DEFAULT_TOOL_DIRECTIVES; "" disables playbook only). # Tool how-to (omit → DEFAULT_TOOL_DIRECTIVES; "" disables playbook only).
tool_directives: str = DEFAULT_TOOL_DIRECTIVES 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 max_tool_result_chars: int = 32_000
parallel_tools: bool = True parallel_tools: bool = True
confirm_destructive: bool = True confirm_destructive: bool = True
path_denylist: list[str] = field(default_factory=list) 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 max_context_tokens: int = 200_000
# How to inject todo stack nags into model context (see agent/todo_nag.py). # 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) 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): class NetworkingConfig(Struct, omit_defaults=True):
"""Host-side network policy for tools such as ``fetch``. """Host-side network policy for tools such as ``fetch``.
+34
View File
@@ -9,6 +9,7 @@ import tomlkit
from .models import ( from .models import (
AgentConfig, AgentConfig,
DatabaseConfig, DatabaseConfig,
McpConfig,
ModelConfig, ModelConfig,
NetworkingConfig, NetworkingConfig,
PluginsConfig, PluginsConfig,
@@ -56,6 +57,29 @@ def _parse_networking(raw: dict[str, object]) -> NetworkingConfig:
return 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( def _parse_providers(
document: tomlkit.TOMLDocument, document: tomlkit.TOMLDocument,
) -> tuple[dict[str, Provider], dict[str, object], dict[str, Provider]]: ) -> tuple[dict[str, Provider], dict[str, object], dict[str, Provider]]:
@@ -125,6 +149,7 @@ class ConfigStore:
_agent: AgentConfig _agent: AgentConfig
_plugins: PluginsConfig _plugins: PluginsConfig
_networking: NetworkingConfig _networking: NetworkingConfig
_mcp: McpConfig
_providers: dict[str, Provider] _providers: dict[str, Provider]
_bad_providers: dict[str, object] _bad_providers: dict[str, object]
_recoverable_providers: dict[str, Provider] _recoverable_providers: dict[str, Provider]
@@ -137,6 +162,7 @@ class ConfigStore:
self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {}))) self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {})))
self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {}))) self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {})))
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {}))) 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) self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document)
@property @property
@@ -187,6 +213,13 @@ class ConfigStore:
"""Typed networking section (e.g. Fake-IP SSRF exemptions).""" """Typed networking section (e.g. Fake-IP SSRF exemptions)."""
return self._networking 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: def set_plugins_enable(self, names: Sequence[str]) -> PluginsConfig:
"""Replace the plugin enable list (in-memory; call :meth:`write` to persist).""" """Replace the plugin enable list (in-memory; call :meth:`write` to persist)."""
cleaned = [name.strip() for name in names if name.strip()] 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._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {})))
self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {}))) self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {})))
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {}))) 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) self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(self._document)
# -- internal sync helpers -- # -- internal sync helpers --
+2
View File
@@ -34,6 +34,8 @@ class Session(PlyngentBase):
model: Mapped[str | None] = mapped_column(String(256), nullable=True) model: Mapped[str | None] = mapped_column(String(256), nullable=True)
# Todo/task stack JSON for multi-step sub-tasks (optional). # Todo/task stack JSON for multi-step sub-tasks (optional).
todo_stack: Mapped[dict[str, object] | None] = mapped_column(JSON(), nullable=True) 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 model request context size (API prompt_tokens preferred).
last_prompt_tokens: Mapped[int | None] = mapped_column(nullable=True) last_prompt_tokens: Mapped[int | None] = mapped_column(nullable=True)
peak_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) await session.refresh(row)
return 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( async def update_session_context_usage(
self, self,
sid: int, 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")) _ = 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 # Latest schema version this build knows. Bump (and append a step to
# _SCHEMA_MIGRATIONS) when a new release adds or changes columns. # _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`` # Ordered (version, step) pairs. Each step receives the current ``session``
# column set (read once) and adds only its own missing columns, so it is # 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. # 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], ...] = ( _SCHEMA_MIGRATIONS: tuple[tuple[int, MigrationStep], ...] = (
(1, _migrate_session_v1), (1, _migrate_session_v1),
(2, _migrate_session_v2), (2, _migrate_session_v2),
(3, _migrate_session_v3), (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 InvalidHttpTimeoutError as InvalidHttpTimeoutError
from .client_factory import ProviderNotSupportedError as ProviderNotSupportedError from .client_factory import ProviderNotSupportedError as ProviderNotSupportedError
from .client_factory import create_client as create_client from .client_factory import create_client as create_client
from .client_factory import normalize_http_timeout as normalize_http_timeout 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 .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 ToolCatalog as ToolCatalog
from .catalog import ToolSource as ToolSource from .catalog import ToolSource as ToolSource
from .catalog import catalog_scope as catalog_scope from .catalog import catalog_scope as catalog_scope
@@ -66,6 +80,7 @@ from .workspace import (
DEFAULT_COMMAND_DENYLIST as DEFAULT_COMMAND_DENYLIST, DEFAULT_COMMAND_DENYLIST as DEFAULT_COMMAND_DENYLIST,
) )
from .workspace import DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS as DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS 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 WorkspaceError as WorkspaceError
from .workspace import WorkspacePolicy as WorkspacePolicy from .workspace import WorkspacePolicy as WorkspacePolicy
from .workspace import active_workspace_policy as active_workspace_policy 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_policy_allowed_commands as clear_policy_allowed_commands
from .workspace import clear_workspace_allowlist as clear_workspace_allowlist from .workspace import clear_workspace_allowlist as clear_workspace_allowlist
from .workspace import clear_workspace_root as clear_workspace_root 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_command_denylist as get_command_denylist
from .workspace import get_path_denylist as get_path_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_policy_confirm_timeout as get_policy_confirm_timeout
from .workspace import get_workspace_root as get_workspace_root from .workspace import get_workspace_root as get_workspace_root
from .workspace import grant_policy_command as grant_policy_command 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 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 pop_owned_temporary_workspaces as pop_owned_temporary_workspaces
from .workspace import remove_workspace_allowlist as remove_workspace_allowlist from .workspace import remove_workspace_allowlist as remove_workspace_allowlist
from .workspace import resolve_path as resolve_path 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_hook as set_policy_confirm_hook
from .workspace import set_policy_confirm_timeout as set_policy_confirm_timeout 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 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: if TYPE_CHECKING:
from collections.abc import Container, Generator from collections.abc import Container, Generator
type ToolSourceKind = Literal["builtin", "plugin"] type ToolSourceKind = Literal["builtin", "plugin", "mcp"]
type ToolSurface = Literal["local", "public"] type ToolSurface = Literal["local", "public"]
@@ -29,8 +29,8 @@ class ToolSource:
def __str__(self) -> str: def __str__(self) -> str:
if self.kind == "builtin": if self.kind == "builtin":
return "builtin" return "builtin"
plugin = self.plugin_id or "?" id_part = self.plugin_id or "?"
return f"plugin:{plugin}" return f"{self.kind}:{id_part}"
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -152,6 +152,13 @@ class ToolCatalog:
def clear(self) -> None: def clear(self) -> None:
self._by_name.clear() 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]: def snapshot(self) -> dict[str, RegisteredTool]:
return dict(self._by_name) 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)`` import time (usually the process catalog). Test ``catalog_scope(empty=True)``
overrides must still see builtins for ``default_tool_definitions``. 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.chat import CHAT_TOOLS
from plyngent.tools.file import FILE_TOOLS from plyngent.tools.file import FILE_TOOLS
from plyngent.tools.net import NET_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 from plyngent.tools.vcs import VCS_TOOLS
for definition in ( for definition in (
*ACCESS_TOOLS,
*FILE_TOOLS, *FILE_TOOLS,
*PROCESS_TOOLS, *PROCESS_TOOLS,
*VCS_TOOLS, *VCS_TOOLS,
+29 -10
View File
@@ -1,20 +1,44 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import re
import sys
from click import style
from plyngent.agent import ToolTag, tool from plyngent.agent import ToolTag, tool
from plyngent.prompting import get_prompt_backend, read_line_with_timeout, run_prompt_async 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")
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) @tool(name="wait", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
async def wait(duration: int, *, reason: str | None = None) -> str: async def wait(duration: int, *, reason: str | None = None) -> str:
"""Wait ``duration`` seconds before continuing. """Wait ``duration`` seconds before continuing.
Interactive sessions show a line prompt: pressing Enter (optionally after Interactive sessions show a two-line prompt: a status line, then an
typing a reason) "disturbs" the wait so the turn continues immediately. optional-reason input — pressing Enter (optionally after typing a reason)
Non-interactive runs simply sleep the full duration. "disturbs" the wait so the turn continues immediately. Non-interactive runs
simply sleep the full duration.
""" """
if duration < 0: if duration < 0:
return "error: duration must not be negative" 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(): if not backend.is_interactive():
await asyncio.sleep(duration) await asyncio.sleep(duration)
return f"waited {duration}s" return f"waited {duration}s"
prompt = WAIT_PROMPT.format(duration=duration) prompt = _wait_prompt(duration, reason=reason)
if reason:
prompt = (
f"Waiting for {duration}s (reason: {reason}). Press Enter to disturb. "
"Feel free to ship any reason before pressing Enter. "
)
line = await run_prompt_async(read_line_with_timeout, prompt, float(duration)) line = await run_prompt_async(read_line_with_timeout, prompt, float(duration))
if line is None: if line is None:
return f"waited {duration}s" return f"waited {duration}s"
+6 -1
View File
@@ -14,7 +14,7 @@ if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
from plyngent.agent.todo_stack import TodoStack 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]: def _default_instance_data() -> PersistentDataView[Any]:
@@ -74,10 +74,15 @@ class SessionState:
todo: TodoStack | None = None todo: TodoStack | None = None
# Host hook after todo tools mutate (e.g. CLI memory persist). # Host hook after todo tools mutate (e.g. CLI memory persist).
on_todo_change: Callable[[], None] | None = None 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). # Soft-confirm grants live map: tool_name → True (Phase 1 key is tool name).
# Durable copy lives under data["grants"] (see plyngent.tools.grants). # Durable copy lives under data["grants"] (see plyngent.tools.grants).
grants: dict[str, bool] = field(default_factory=dict) grants: dict[str, bool] = field(default_factory=dict)
extras: dict[str, Any] = 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: def has_grant(self, tool_name: str) -> bool:
return bool(self.grants.get(tool_name)) return bool(self.grants.get(tool_name))
+3 -3
View File
@@ -4,7 +4,7 @@ import json
import shlex import shlex
from typing import TYPE_CHECKING, cast 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: if TYPE_CHECKING:
from collections.abc import Mapping, Sequence 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: if not isinstance(path, str) or not path:
return None return None
try: try:
target = resolve_path(path) target = resolve_path(path, required=AccessMode.WRITE)
except WorkspaceError: except WorkspaceError:
# Path policy will fail later; no soft-confirm without a resolved target. # Path policy will fail later; no soft-confirm without a resolved target.
return None return None
@@ -160,7 +160,7 @@ def _copy_path_reason(args: Mapping[str, object]) -> str | None:
if not isinstance(dst, str) or not dst: if not isinstance(dst, str) or not dst:
return f"copy {src!r} → {dst!r} (overwrite)" return f"copy {src!r} → {dst!r} (overwrite)"
try: try:
target = resolve_path(dst) target = resolve_path(dst, required=AccessMode.WRITE)
except WorkspaceError: except WorkspaceError:
return None return None
if target.exists(): if target.exists():
+2 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from plyngent.agent import ToolTag, lineno_read_lines, lineno_read_mtime, tool from plyngent.agent import ToolTag, lineno_read_lines, lineno_read_mtime, tool
from plyngent.tools.workspace import resolve_path from plyngent.tools.workspace import AccessMode, resolve_path
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
@@ -114,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. ``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(): if not target.is_file():
return f"error: not a file: {path}" return f"error: not a file: {path}"
+2 -2
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from plyngent.agent import ToolTag, tool from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path from plyngent.tools.workspace import AccessMode, resolve_path
def _count_non_overlapping(text: str, needle: str) -> int: def _count_non_overlapping(text: str, needle: str) -> int:
@@ -60,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" return "error: old_string must not be empty"
if max_replaces < 1: if max_replaces < 1:
return "error: max_replaces must be >= 1" return "error: max_replaces must be >= 1"
target = resolve_path(path) target = resolve_path(path, required=AccessMode.WRITE)
if not target.is_file(): if not target.is_file():
return f"error: not a file: {path}" return f"error: not a file: {path}"
text = target.read_text(encoding="utf-8", errors="replace") text = target.read_text(encoding="utf-8", errors="replace")
+19 -9
View File
@@ -4,7 +4,7 @@ import shutil
from pathlib import Path from pathlib import Path
from plyngent.agent import ToolTag, invalidate_lineno_read, tool 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: 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: def _resolve_pair(src: str, dst: str) -> tuple[Path, Path, Path] | str:
try: try:
source = resolve_path(src) source = resolve_path(src, required=AccessMode.READ)
dest = resolve_path(dst) dest = resolve_path(dst, required=AccessMode.WRITE)
root = get_workspace_root() root = get_workspace_root()
except WorkspaceError as exc: except WorkspaceError as exc:
return f"error: {exc}" return f"error: {exc}"
return source, dest, root 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.""" """Resolve copy/move destination; return error string on conflict."""
if source.is_file() and dest.is_dir(): 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 dest.exists() or dest.is_symlink():
if not overwrite: if not overwrite:
return f"error: destination exists: {dst_label} (set overwrite=true)" return f"error: destination exists: {dst_label} (set overwrite=true)"
@@ -56,7 +66,7 @@ def _copy_or_move_validated(
overwrite: bool, overwrite: bool,
action: str, action: str,
) -> 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): if isinstance(prepared, str):
return prepared return prepared
dest = prepared dest = prepared
@@ -72,12 +82,12 @@ def _copy_or_move_validated(
# new. copy2/copytree preserve the source mtime, so the freshness # new. copy2/copytree preserve the source mtime, so the freshness
# check alone could miss the change — invalidate explicitly. # check alone could miss the change — invalidate explicitly.
invalidate_lineno_read(str(dest)) 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() moved = Path(shutil.move(str(source), str(dest))).resolve()
# shutil.move preserves mtime too; drop read state for both paths. # shutil.move preserves mtime too; drop read state for both paths.
invalidate_lineno_read(str(source)) invalidate_lineno_read(str(source))
invalidate_lineno_read(str(moved)) 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: except WorkspaceError as exc:
return f"error: {exc}" return f"error: {exc}"
except OSError as exc: except OSError as exc:
@@ -147,7 +157,7 @@ async def delete_path(path: str, *, recursive: bool = False) -> str:
``recursive=true`` (uses ``shutil.rmtree``). The workspace root cannot be deleted. ``recursive=true`` (uses ``shutil.rmtree``). The workspace root cannot be deleted.
""" """
try: try:
target = resolve_path(path) target = resolve_path(path, required=AccessMode.WRITE)
root = get_workspace_root() root = get_workspace_root()
except WorkspaceError as exc: except WorkspaceError as exc:
return f"error: {exc}" return f"error: {exc}"
+2 -2
View File
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
from plyngent.agent import ToolTag, tool from plyngent.agent import ToolTag, tool
from plyngent.tools.file.tree import VCS_DIR_NAMES 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: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
@@ -57,7 +57,7 @@ def _collect_glob(
def _resolve_glob_base(path: str) -> tuple[Path, Path] | str: def _resolve_glob_base(path: str) -> tuple[Path, Path] | str:
try: try:
root = get_workspace_root() root = get_workspace_root()
base = resolve_path(path) base = resolve_path(path, required=AccessMode.READ)
except WorkspaceError as exc: except WorkspaceError as exc:
return f"error: {exc}" return f"error: {exc}"
if not base.is_dir(): if not base.is_dir():
+2 -2
View File
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
from plyngent.agent import ToolTag, tool from plyngent.agent import ToolTag, tool
from plyngent.tools.file.tree import VCS_DIR_NAMES 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: if TYPE_CHECKING:
from collections.abc import Iterator 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: def _resolve_files(path: str, *, skip_hidden_dirs: bool) -> list[Path] | str:
try: try:
base = resolve_path(path) base = resolve_path(path, required=AccessMode.READ)
except WorkspaceError as exc: except WorkspaceError as exc:
return f"error: {exc}" return f"error: {exc}"
if not base.exists(): if not base.exists():
+2 -2
View File
@@ -1,13 +1,13 @@
from __future__ import annotations from __future__ import annotations
from plyngent.agent import ToolTag, tool 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 | ToolTag.READ_ONLY) @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def listdir(path: str = ".") -> str: async def listdir(path: str = ".") -> str:
"""List entries in a directory under the workspace (name and type).""" """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(): if not target.is_dir():
return f"error: not a directory: {path}" return f"error: not a directory: {path}"
lines: list[str] = [] lines: list[str] = []
+3 -3
View File
@@ -5,7 +5,7 @@ from typing import cast
from plyngent.agent import ToolTag, mark_lineno_read, tool from plyngent.agent import ToolTag, mark_lineno_read, tool
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
from plyngent.tools.truncate_token import TruncateToken, truncate_with_token, truncation_marker from plyngent.tools.truncate_token import TruncateToken, truncate_with_token, truncation_marker
from plyngent.tools.workspace import resolve_path from plyngent.tools.workspace import AccessMode, resolve_path
_LINENO_WIDTH = 6 _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). always refer to the raw file text (never the ``L{begin}-{end}`` header).
Missing paths and directories are distinguished for the caller. Missing paths and directories are distinguished for the caller.
""" """
target = resolve_path(path) target = resolve_path(path, required=AccessMode.READ)
if not target.exists(): if not target.exists():
return None, f"error: file not found: {path}" return None, f"error: file not found: {path}"
if not target.is_file(): if not target.is_file():
@@ -72,7 +72,7 @@ async def read_file(
so ``get_truncated`` can continue reading the rest without a new request. so ``get_truncated`` can continue reading the rest without a new request.
Pass ``max_chars < 1`` to read unbounded. Pass ``max_chars < 1`` to read unbounded.
""" """
target = resolve_path(path) target = resolve_path(path, required=AccessMode.READ)
text, err = read_raw_text(path) text, err = read_raw_text(path)
if err: if err:
return err return err
+2 -2
View File
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING, Literal
from plyngent.agent import ToolTag, tool 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: if TYPE_CHECKING:
from collections.abc import Sequence from collections.abc import Sequence
@@ -270,7 +270,7 @@ async def tree(
return "error: max_entries must be >= 1" return "error: max_entries must be >= 1"
try: try:
origin = resolve_path(path) origin = resolve_path(path, required=AccessMode.READ)
except WorkspaceError as exc: except WorkspaceError as exc:
return f"error: {exc}" return f"error: {exc}"
if not origin.is_dir(): if not origin.is_dir():
+2 -2
View File
@@ -1,13 +1,13 @@
from __future__ import annotations from __future__ import annotations
from plyngent.agent import ToolTag, tool 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 | ToolTag.YOLO) @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def write_file(path: str, content: str) -> str: async def write_file(path: str, content: str) -> str:
"""Write text content to a file under the workspace (creates parents).""" """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.parent.mkdir(parents=True, exist_ok=True)
_ = target.write_text(content, encoding="utf-8") _ = target.write_text(content, encoding="utf-8")
# The write changes the file's mtime, so edit_lineno's freshness check # The write changes the file's mtime, so edit_lineno's freshness check
+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
+2 -1
View File
@@ -10,6 +10,7 @@ if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
from plyngent.tools.workspace import ( from plyngent.tools.workspace import (
AccessMode,
WorkspaceError, WorkspaceError,
check_command_allowed, check_command_allowed,
get_workspace_root, get_workspace_root,
@@ -61,7 +62,7 @@ def format_command_result(
def resolve_workdir(cwd: str) -> tuple["Path", str]: def resolve_workdir(cwd: str) -> tuple["Path", str]:
"""Return (absolute workdir, display path relative to workspace when possible).""" """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(): if not workdir.is_dir():
msg = f"not a directory: {cwd}" msg = f"not a directory: {cwd}"
raise WorkspaceError(msg) 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_backend import PtyHandle, pty_available, spawn_pty
from plyngent.tools.process.pty_terminal import sanitize_pty_output_for_tool 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: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@@ -122,7 +122,7 @@ class PtyManager:
msg = "PTY is not available on this platform (Windows needs pywinpty)" msg = "PTY is not available on this platform (Windows needs pywinpty)"
raise WorkspaceError(msg) raise WorkspaceError(msg)
check_command_allowed(command) check_command_allowed(command)
workdir = resolve_path(cwd) workdir = resolve_path(cwd, required=AccessMode.EXEC)
if not workdir.is_dir(): if not workdir.is_dir():
msg = f"cwd is not a directory: {cwd}" msg = f"cwd is not a directory: {cwd}"
raise WorkspaceError(msg) raise WorkspaceError(msg)
+99 -12
View File
@@ -1,13 +1,14 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import IntEnum
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
if TYPE_CHECKING: 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( 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). # Timed human override for command denylist (independent of YOLO soft-confirm).
DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS = 30.0 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. # Hook: (basename, argv, timeout_seconds) -> True allow for this session basename.
type PolicyConfirmHook = Callable[[str, Sequence[str], float], bool] type PolicyConfirmHook = Callable[[str, Sequence[str], float], bool]
@@ -63,6 +94,10 @@ class WorkspacePolicy:
policy_allowed_commands: set[str] = field(default_factory=set) policy_allowed_commands: set[str] = field(default_factory=set)
policy_confirm_hook: PolicyConfirmHook | None = None policy_confirm_hook: PolicyConfirmHook | None = None
policy_confirm_timeout_seconds: float = DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS 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: def _bound_instance() -> InstanceState | None:
@@ -71,6 +106,12 @@ def _bound_instance() -> InstanceState | None:
return get_instance() return get_instance()
def _bound_session() -> SessionState | None:
from plyngent.tools.context import get_session
return get_session()
def require_bound_instance() -> InstanceState: def require_bound_instance() -> InstanceState:
"""Return the bound instance or raise :class:`WorkspaceError`.""" """Return the bound instance or raise :class:`WorkspaceError`."""
instance = _bound_instance() instance = _bound_instance()
@@ -243,11 +284,55 @@ def _under_any_root(resolved: Path, instance: InstanceState, policy: WorkspacePo
return False return False
def resolve_path(path: str | Path) -> Path: def granted_access_mode(resolved: Path) -> AccessMode | None:
"""Resolve ``path`` under the workspace root or an allowlisted temp root. """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 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() instance = require_bound_instance()
policy = instance.workspace policy = instance.workspace
@@ -256,15 +341,17 @@ def resolve_path(path: str | Path) -> Path:
if not candidate.is_absolute(): if not candidate.is_absolute():
candidate = root / candidate candidate = root / candidate
resolved = candidate.expanduser().resolve() resolved = candidate.expanduser().resolve()
if not _under_any_root(resolved, instance, policy): if not _under_any_root(resolved, instance, policy) and not _path_grant_covers(resolved, required):
msg = f"path escapes workspace root ({root}): {path}" msg = (
f"path escapes workspace root ({root}): {path}; "
"call request_directory_access to request access to a directory outside the workspace"
)
raise WorkspaceError(msg) raise WorkspaceError(msg)
# Normalize separators so denylist entries like ``/secrets/`` match on Windows. # Normalize separators so denylist entries like ``/secrets/`` match on Windows.
resolved_str = str(resolved).replace("\\", "/") matched = denylist_match(resolved)
for pattern in policy.path_denylist: if matched is not None:
if pattern and pattern.replace("\\", "/") in resolved_str: msg = f"path denied by policy (matched {matched!r}): {path}"
msg = f"path denied by policy (matched {pattern!r}): {path}" raise WorkspaceError(msg)
raise WorkspaceError(msg)
return resolved return resolved
+9 -1
View File
@@ -98,11 +98,19 @@ def _result(content: str) -> ToolResultEvent:
async def test_pretty_read_file_done(capsys: pytest.CaptureFixture[str]) -> None: async def test_pretty_read_file_done(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_read_call('{"path": "a.txt"}'), _result("L1-4\none\ntwo\n")])) await render_events(_aiter([_read_call('{"path": "a.txt"}'), _result("L1-4\none\ntwo\n")]))
out = capsys.readouterr().out out = capsys.readouterr().out
assert "* Read 'a.txt' (done)" in out assert "* Read 'a.txt' L1-4 (done)" in out
assert "[tool]" not in out assert "[tool]" not in out
assert "[tool ok]" not in out 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: async def test_pretty_read_file_statuses(capsys: pytest.CaptureFixture[str]) -> None:
cases = [ cases = [
("error: file not found: a.txt", "(file not found)"), ("error: file not found: a.txt", "(file not found)"),
+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.limits import prompt_continue_limit, prompt_continue_limit_async
from plyngent.cli.retry import run_cancellable from plyngent.cli.retry import run_cancellable
from plyngent.prompting import temporary_backend
from tests.test_prompting import ScriptedBackend
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
@@ -39,12 +41,16 @@ def test_nested_pause_depth() -> None:
def test_prompt_continue_limit_under_pause(monkeypatch: pytest.MonkeyPatch) -> None: def test_prompt_continue_limit_under_pause(monkeypatch: pytest.MonkeyPatch) -> None:
def _confirm(*_a: object, **_k: object) -> bool: backend = ScriptedBackend(["y"])
assert allow_task_cancel() is False
return True
monkeypatch.setattr("click.confirm", _confirm) def _read_line(prompt: str, *, default: str | None = None, completions: object = None) -> str:
assert prompt_continue_limit("too many rounds") is True 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: 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: async def test_prompt_continue_limit_async(monkeypatch: pytest.MonkeyPatch) -> None:
def _confirm(*_a: object, **_k: object) -> bool: del monkeypatch
return True with temporary_backend(ScriptedBackend(["y"])):
assert await prompt_continue_limit_async("too many rounds") is True
monkeypatch.setattr("click.confirm", _confirm)
assert await prompt_continue_limit_async("too many rounds") is True
async def test_sigint_cancels_after_prompt_pause() -> None: async def test_sigint_cancels_after_prompt_pause() -> None:
+115 -3
View File
@@ -1,28 +1,106 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from plyngent.cli.limits import ( from plyngent.cli.limits import (
auto_continue_enabled,
format_tool_confirm_box, format_tool_confirm_box,
install_cli_limit_hooks, install_cli_limit_hooks,
prompt_confirm_tool, prompt_confirm_tool,
prompt_continue_limit, 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 plyngent.tools.process.pty_session import PtyManager
from tests.test_prompting import ScriptedBackend 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: 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): with temporary_backend(backend):
assert prompt_continue_limit("hit a wall") is True assert prompt_continue_limit("hit a wall") is True
def test_prompt_continue_limit_no() -> None: def test_prompt_continue_limit_no() -> None:
backend = ScriptedBackend([], confirms=[False]) backend = ScriptedBackend(["n"])
with temporary_backend(backend): with temporary_backend(backend):
assert prompt_continue_limit("hit a wall") is False 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: def test_prompt_confirm_tool_default_deny() -> None:
backend = ScriptedBackend([], confirms=[False]) backend = ScriptedBackend([], confirms=[False])
with temporary_backend(backend): with temporary_backend(backend):
@@ -54,3 +132,37 @@ def test_policy_confirm_noninteractive_denies() -> None:
with temporary_backend(NonInteractiveBackend()): with temporary_backend(NonInteractiveBackend()):
assert prompt_policy_command_confirm("sudo", ["sudo", "id"], 1.0) is False 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()
+26
View File
@@ -94,6 +94,32 @@ async def test_help_and_clear(state: ReplState) -> None:
assert state.agent.messages == [] 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: async def test_help_command_usage_line(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
assert await handle_slash(state, "/help compact") is True assert await handle_slash(state, "/help compact") is True
out = capsys.readouterr().out out = capsys.readouterr().out
+39
View File
@@ -30,6 +30,8 @@ from plyngent.memory import MemoryStore
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from plyngent.agent import AgentEvent
def test_default_retry_delays_schedule() -> None: def test_default_retry_delays_schedule() -> None:
assert DEFAULT_MAX_AUTO_RETRIES == 10 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 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 test_manual_retry_after_exhausted(monkeypatch: pytest.MonkeyPatch) -> None:
async def no_wait(_seconds: float) -> bool: async def no_wait(_seconds: float) -> bool:
return True return True
+48
View File
@@ -19,12 +19,15 @@ def test_agent_section_defaults(tmp_path: Path) -> None:
store = load(path) store = load(path)
assert store.agent_config.system_prompt == DEFAULT_SYSTEM_PROMPT assert store.agent_config.system_prompt == DEFAULT_SYSTEM_PROMPT
assert store.agent_config.tool_directives == DEFAULT_TOOL_DIRECTIVES 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 "professional coding agent" in store.agent_config.system_prompt
assert "### Workspace" in store.agent_config.tool_directives assert "### Workspace" in store.agent_config.tool_directives
assert store.agent_config.max_tool_result_chars == 32_000 assert store.agent_config.max_tool_result_chars == 32_000
assert store.agent_config.parallel_tools is True assert store.agent_config.parallel_tools is True
assert store.agent_config.confirm_destructive is True assert store.agent_config.confirm_destructive is True
assert store.agent_config.path_denylist == [] 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.agent_config.max_context_tokens == 200_000
assert store.plugins_config.enable == [] assert store.plugins_config.enable == []
assert store.plugins_config.disable == [] 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 persona", "") == "Only persona"
assert compose_agent_system_content("", "Only tools") == "Only tools" assert compose_agent_system_content("", "Only tools") == "Only tools"
assert compose_agent_system_content("Persona", "Tools") == "Persona\n\nTools" 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: def test_agent_section_parse(tmp_path: Path) -> None:
@@ -78,6 +84,7 @@ parallel_tools = false
confirm_destructive = false confirm_destructive = false
path_denylist = ["/secrets/", ".ssh/"] path_denylist = ["/secrets/", ".ssh/"]
max_context_tokens = 5000 max_context_tokens = 5000
auto_continue_limits = true
""", """,
encoding="utf-8", encoding="utf-8",
) )
@@ -89,6 +96,7 @@ max_context_tokens = 5000
assert store.agent_config.confirm_destructive is False assert store.agent_config.confirm_destructive is False
assert store.agent_config.path_denylist == ["/secrets/", ".ssh/"] assert store.agent_config.path_denylist == ["/secrets/", ".ssh/"]
assert store.agent_config.max_context_tokens == 5000 assert store.agent_config.max_context_tokens == 5000
assert store.agent_config.auto_continue_limits is True
composed = compose_agent_system_content( composed = compose_agent_system_content(
store.agent_config.system_prompt, store.agent_config.system_prompt,
store.agent_config.tool_directives, 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: def test_plugins_section_parse(tmp_path: Path) -> None:
path = tmp_path / "c.toml" path = tmp_path / "c.toml"
_ = path.write_text( _ = 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 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: async def test_session_context_usage_roundtrip(store: MemoryStore) -> None:
session = await store.create_session(name="usage") session = await store.create_session(name="usage")
row = await store.update_session_context_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" db_path = tmp_path / "fresh.db"
store = await MemoryStore.open(DatabaseConfig(url=str(db_path))) store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
await store.close() await store.close()
assert await _user_version(db_path) == 3 assert await _user_version(db_path) == 4
columns = await _session_columns(db_path) 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: 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") session = await store.create_session(name="migrated")
await store.close() await store.close()
assert await _user_version(db_path) == 3 assert await _user_version(db_path) == 4
columns = await _session_columns(db_path) 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 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) session = await store.create_session(name="partial", workspace=tmp_path)
await store.close() await store.close()
assert await _user_version(db_path) == 3 assert await _user_version(db_path) == 4
columns = await _session_columns(db_path) columns = await _session_columns(db_path)
assert "workspace" in columns 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 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" db_path = tmp_path / "stamped.db"
store = await MemoryStore.open(DatabaseConfig(url=str(db_path))) store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
await store.close() 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))) store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
await store.close() 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)
+2 -1
View File
@@ -6,6 +6,7 @@ import inspect
from plyngent.agent import ToolRegistry, ToolTag, tool from plyngent.agent import ToolRegistry, ToolTag, tool
from plyngent.tools import default_tool_definitions, register_builtin_tools 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.catalog import ToolCatalog, ToolSource, catalog_scope, get_catalog, registration_source
from plyngent.tools.chat import CHAT_TOOLS from plyngent.tools.chat import CHAT_TOOLS
from plyngent.tools.file import FILE_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: def test_default_tool_names_match_group_lists() -> None:
register_builtin_tools() register_builtin_tools()
selected = default_tool_definitions(surface="local") 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 sorted(t.name for t in selected) == sorted(t.name for t in groups)
assert len(selected) == len(groups) assert len(selected) == len(groups)
+48
View File
@@ -144,3 +144,51 @@ async def test_wait_tool_times_out(monkeypatch) -> None:
registry = ToolRegistry([wait]) registry = ToolRegistry([wait])
out = await registry.execute("wait", '{"duration": 3}') out = await registry.execute("wait", '{"duration": 3}')
assert out == "waited 3s" 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
+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"})]
+88
View File
@@ -3,13 +3,18 @@ from __future__ import annotations
import pytest import pytest
from plyngent.tools import ( from plyngent.tools import (
AccessMode,
WorkspaceError, WorkspaceError,
active_workspace_policy,
check_command_allowed, check_command_allowed,
get_workspace_root, get_workspace_root,
mode_covers,
parse_access_mode,
resolve_path, resolve_path,
set_command_denylist, set_command_denylist,
set_path_denylist, set_path_denylist,
) )
from plyngent.tools.context import SessionState, bind_session
def test_resolve_relative_and_absolute(workspace: object) -> None: def test_resolve_relative_and_absolute(workspace: object) -> None:
@@ -27,6 +32,89 @@ def test_escape_rejected(workspace: object) -> None:
_ = resolve_path("../outside") _ = 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: def test_path_denylist(workspace: object) -> None:
from pathlib import Path from pathlib import Path