mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-08 16:37:55 +08:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2769fb62de | |||
| cb4e9b3744 | |||
| 4b90718191 | |||
| 0e8b0f8bd4 | |||
| 2369592473 | |||
| 17813281d8 | |||
| c4a287c464 | |||
| 7b7c90b8ee | |||
| 290f3daf60 | |||
| f3bd511251 | |||
| b197c98ce9 | |||
| be8a0111ec | |||
| 35595d8b04 | |||
| 036e0b40ad | |||
| 10402f5be6 | |||
| 557300ff4f | |||
| aafbc207a3 | |||
| 4c597274e7 | |||
| 8ec63aa6a2 | |||
| 029ac47350 | |||
| 154509b929 | |||
| e9ee71e853 | |||
| c6a94e0b66 | |||
| d7293eeb16 |
@@ -60,7 +60,7 @@ lmproto/deepseek/anthropic/ ← DeepSeek Anthropic client (extends Anthropic
|
||||
|
||||
### Config (`config/`)
|
||||
|
||||
TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]` section. Default path via platformdirs. Providers may set `timeout` as a float or `{ connect, read }` (`HttpTimeoutConfig`); omitted → product defaults (10s connect / 600s read). `DeepseekProvider.convention` (Literal: `""`/`"openai"`/`"anthropic"`/`"responses"`) selects the DeepSeek API surface — `"responses"` → OpenAI Responses, `"anthropic"` → Anthropic Messages on `https://api.deepseek.com/anthropic` (default url follows the convention); a per-model `ModelConfig.convention` overrides it (empty = inherit). `[mcp]` (`McpConfig`): `servers.<name>` stdio server definitions (`McpServerConfig`: `command`/`args`/`env`/`cwd`/`timeout`/`read_only`, `url` reserved for streamable HTTP) + `disable` gate; defining a server opts it in. `[skills]` (`SkillsConfig`): `paths` (explicit roots first), `discover` (`plyngent` user dir + `claude` dirs) with `include_project_roots`, `enabled`, and `allow_write` (standing write grant); a malformed section degrades to defaults like `[agent]` / `[mcp]`.
|
||||
TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]` section. Default path via platformdirs. Providers may set `timeout` as a float or `{ connect, read }` (`HttpTimeoutConfig`); omitted → product defaults (10s connect / 600s read). `DeepseekProvider.convention` (Literal: `""`/`"openai"`/`"anthropic"`/`"responses"`) selects the DeepSeek API surface — `"responses"` → OpenAI Responses, `"anthropic"` → Anthropic Messages on `https://api.deepseek.com/anthropic` (default url follows the convention); a per-model `ModelConfig.convention` overrides it (empty = inherit). `[mcp]` (`McpConfig`): `servers.<name>` stdio server definitions (`McpServerConfig`: `command`/`args`/`env`/`cwd`/`timeout`/`read_only`, `url` reserved for streamable HTTP) + `disable` gate; defining a server opts it in. `[skills]` (`SkillsConfig`): `paths` (explicit roots first), `discover` (`plyngent` user dir + `claude` dirs) with `include_project_roots`, `enabled`, and `allow_write` (standing write grant); a malformed section degrades to defaults like `[agent]` / `[mcp]`. `[trash]` (`TrashConfig`): `enabled` — whether `delete_path` moves to the system trash or removes the path outright; a typo degrades to the default (trash on).
|
||||
|
||||
`reasoning.py`: thinking strength as `reasoning_effort` (`ReasoningEffortConfig`: `""` = send nothing | `none` … `high` … `max`) and `thinking_budget_tokens`, settable on `[agent]`, a provider, or a model entry — `resolve_reasoning(agent, provider, model)` merges the layers per field (model > provider > agent) and clamps `max` (DeepSeek chat only) to `xhigh` for every other surface, returning a `ReasoningConfig` the agent loop and `create_client` consume.
|
||||
|
||||
@@ -83,8 +83,9 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
||||
- **Kind-based dispatch**: clients expose `kind` (`chat_completions` | `responses` | `messages`). Agent history stays chat-completions-shaped; transport adapters return synthetic `ChatCompletionResponse` / stream chunks.
|
||||
- **`responses_bridge` / `responses_dispatch`**: chat ↔ OpenAI Responses; stream text/reasoning deltas (`response.reasoning_text.done` repeats what its fragments spelled out, so the loop streams each part once and still keeps the whole text in the round's message); tool calls + usage on `response.completed`; a request carrying `tools` returns every round's reasoning as a `reasoning` item of `reasoning_text` parts — "必须完整回传 `reasoning_content` … 即使该轮模型未实际进行工具调用" (a missing item and an empty part are both a 400, so a round that kept none sends a blank line) — and a round's `function_call` items stay contiguous ahead of their outputs (`call, output, call, output` was a 400: the second call reads as a round of its own without reasoning); reasoning is read back from the item's `content` parts.
|
||||
- **`messages_bridge` / `messages_dispatch`**: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments; `thinking` from the request's `reasoning_effort` (level → token budget, floor 1024, `max_tokens` grown when the budget would leave no room) plus the client's configured `thinking_budget_tokens`; a thinking block the model refuses (from Claude 4.7 on, `thinking.type.enabled` is a 400 asking for adaptive thinking + `output_config.effort`; a model whose thinking is always on refuses `thinking.type.disabled`) gets one retry with the shape it takes — adaptive thinking with the level as the effort, or no block at all — and the refusal is remembered per model on the client, so only its first request pays the round trip (only a refusal that arrives before the stream yields anything is retried; a later failure has already been seen); a server tool (`server_tool_use` + its `web_search_tool_result`) is the surface's own business — it streams `input_json_delta` fragments like a call but nothing local asked for it, so only a `tool_use` block becomes a tool call and nothing is executed; `thinking` and the two search blocks are kept as `AssistantChatMessage.provider_blocks` (the thinking text also lands in `reasoning_content`, so it streams as reasoning) and go back with the round, which is also why a round that kept only those blocks is not an empty completion; a chat-completions request drops the field (`compat.coerce_chat_messages`).
|
||||
- **Images** (see `doc/images.md`): a user turn's `content` may be a part array (`ChatTextPart` + `ChatImagePart`), and a tool result carries its pictures in `ToolChatMessage.images`; each surface lifts them into what it can express — chat-completions content blocks (tool-result images move into the user turn after the tool run, since a `tool` message holds text only), Anthropic `image` blocks (inside `tool_result` for a tool result), Responses `input_image` parts (in a `function_call_output` item). Pictures belong to user turns: an image in a system or assistant message is a provider 400. `agent/images.py` sniffs the format from the bytes and caps an inline picture at 8 MiB, because every round re-sends it; `[providers.*.models.*].image_in` is the opt-in.
|
||||
- **Provider-side tools**: `provider_tools` on the OpenAI **and** DeepSeek presets (list of dicts; `[{type="web_search"}]` when omitted; `[]` disables) threaded via `ChatAgent`/`run_chat_loop` into Responses and Messages; never executed by `ToolRegistry`. The Messages surface declares its own server tool instead — a bare `web_search` becomes `{type="web_search_20250305", name="web_search"}` (the search then runs inside the response, and its `encrypted_content` results go back verbatim with the round — `AssistantChatMessage.provider_blocks`) — and a DeepSeek provider on a surface that ignores hosted tools (`config.routing.local_web_search`) gets the local `web_search` tool from `tools/net/search.py` instead.
|
||||
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name. Before running, `execute` answers a call that omits a required argument with the accepted names (`_missing_required_error`) — the handler's bare `TypeError` never reaches the model, and no confirm prompt fires for a call that cannot run. Shape mistakes inside a *present* argument are the tool's own job (see the `chat` bullet). `@tool(writes=("path",))` names a handler's file arguments: one batch applies calls that name the same file one at a time, in call order — the lock is taken before the confirm prompt, so a pending prompt cannot be overtaken — while different files keep running in parallel.
|
||||
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name. A handler returns its text, or a `ToolOutput(text, images)` when it also produced a picture: `execute` keeps the text-only contract and `execute_output` returns the pair, which `run_chat_loop` stores as the tool message's text plus its `images` (`view_image`, an image body from `fetch_local`, and an MCP `image` content block all take that path). Before running, `execute` answers a call that omits a required argument with the accepted names (`_missing_required_error`) — the handler's bare `TypeError` never reaches the model, and no confirm prompt fires for a call that cannot run. Shape mistakes inside a *present* argument are the tool's own job (see the `chat` bullet). `@tool(writes=("path",))` names a handler's file arguments: one batch applies calls that name the same file one at a time, in call order — the lock is taken before the confirm prompt, so a pending prompt cannot be overtaken — while different files keep running in parallel.
|
||||
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`; resolved `reasoning_effort` on every request (see Config). A round the provider cut off at the output limit is kept, not failed: the answer and its thinking blocks stay in `messages`, the round's tool calls are dropped (a half-written call must not run — the model re-issues it), and up to `max_truncation_continues` (2) further rounds carry on from a truncation notice; 0 spends the turn as unfinished instead, for `/retry`.
|
||||
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `provider_tools`; `retry()` continues incomplete turns (user-only, after committed tools — does not re-run those tools — or after a kept truncated answer) and `pending_retry_text` reads a truncation notice as unfinished work.
|
||||
- **`/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. The default summarizer system prompt states that tool calls are unavailable (a `compact_system_prompt` override replaces that default wholesale).
|
||||
@@ -98,11 +99,11 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
||||
|
||||
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots **or** a directory-access grant (mode-aware: `read` < `write` < `exec`, default required `write`; a file grant matches only that file); path substring denylist (wins over grants); command denylist via `match_command_denylist` (checks every program of the wrapper/`run` chain, resolved by `tools/command_scan.py`; an entry is a program, plus optional argument words that make it dangerous — `systemctl reboot poweroff …` matches `systemctl reboot` and `systemctl start reboot.target`, not `systemctl status`; basenames are lower-cased, so `RM` / `rm.exe` count; defaults cover privilege `sudo`/`su`/`doas`/`pkexec`/`run0`/`systemd-run`, destructive `rm`/`mkfs`/`dd`/`chown`/`mount`/…, and power state `shutdown`/`reboot`/`poweroff`/`halt`/`init`/`telinit`/`kexec`/`pm-*`/`s2*`; CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); escape error names the `request_directory_access` mode the call needs (`call request_directory_access with mode='write'`).
|
||||
- **`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` (refuses to replace an existing file unless `overwrite=true`; that replacement is the one soft-confirmed), `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `regex_files` (Python `re` per line, not a glob; literal file/dir `path`, no glob expansion; skips VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path`, `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume any truncated result via its `truncate_token=...`).
|
||||
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range; an image file is refused with a pointer to `view_image`), `view_image` (a picture reaches the model as an image part; format sniffed from the bytes, `detail` = `auto` default / `low` / `high` / `original`), `write_file` (refuses to replace an existing file unless `overwrite=true`; that replacement is the one soft-confirmed), `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `regex_files` (Python `re` per line, not a glob; literal file/dir `path`, no glob expansion; skips VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path` (moves the path into the system trash — freedesktop trash / Finder trash / Recycle Bin — so a delete can be undone; `permanent=true`, `[trash] enabled = false`, or a path under the OS temp dir delete it for good, and a trashing that fails reports the reason and leaves the path alone; [doc/trash.md](doc/trash.md)), `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume any truncated result via its `truncate_token=...`).
|
||||
- **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_batch` (serial steps, `pipe_out` on provider, `mix_stderr`, `stop_on_error`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (escapes) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
|
||||
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`\xHH`, `ctrl+x`, `key=esc`); secrets via `ask_into_pty` (not `write_pty` data); session limit/idle TTL/output budget; close terminate→kill.
|
||||
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget. The prompt accepts `y`/empty (once), `n`, or `yyy` = stop asking for the rest of the turn (`cli.limits` module flag, reset by `run_turn_with_retries`, `/btw`, session switches, and chat exit). `--auto-continue` / `[agent] auto_continue_limits` set the process-wide default (covers one-shot/PTY); the flag does not imply YOLO and vice versa.
|
||||
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for interpreters — bare shells/REPLs, scripts, and `-c` one-liners alike (`-c` is code only for an interpreter, so `grep -c` / `od -c` stay unflagged; argv + code preview) —, detached runners (`nohup`), and wrapper options that carry a command line (`env -S "…"`, `su -c "…"`, whose text is printed under its own `env -S:` label), each resolved past env wrappers and `<launcher> run`; 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). A round of tool calls asks **once**: `ToolRegistry.prepare_batch_confirm` pre-scans it (unknown tools, undecodable arguments, and calls missing a required argument are left out — nobody is asked about a call that cannot run), two or more soft reasons collapse into one `ToolBatchConfirmHook` prompt whose `BatchConfirmDecision` `execute(..., decision=…)` applies to each call (CLI box lists every call: `y` = allow these, `m` = review them one at a time through the per-call prompt, `N` = deny with one comment shared by all), the answer covers that round only (the next one asks again), and a lone request keeps the plain one-call prompt.
|
||||
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for interpreters — bare shells/REPLs, scripts, and `-c` one-liners alike (`-c` is code only for an interpreter, so `grep -c` / `od -c` stay unflagged; argv + code preview) —, detached runners (`nohup`), and wrapper options that carry a command line (`env -S "…"`, `su -c "…"`, whose text is printed under its own `env -S:` label), each resolved past env wrappers and `<launcher> run`; deny may include a user comment for the model. The `delete_path` confirm names what will happen — `move path 'x' to the system trash`, or `delete path 'x' permanently` when the call asks for it, the host cleared `[trash]`, or the path is scratch — and the pretty line prints `(to trash)` for a delete that landed there. Session YOLO: `/yolo on|off|once` and `--yes` (skip the soft confirms of `YOLO`-tagged tools — interpreter runs included — while untagged ones still prompt and a command that escalates privileges keeps its confirm through `DangerReason(yolo_skip=False)`; hard denylists unchanged; `once` expires after the next user turn). A round of tool calls asks **once**: `ToolRegistry.prepare_batch_confirm` pre-scans it (unknown tools, undecodable arguments, calls missing a required argument, and calls YOLO already covers are left out — nobody is asked about a call that cannot run or will not ask), two or more soft reasons collapse into one `ToolBatchConfirmHook` prompt whose `BatchConfirmDecision` `execute(..., decision=…)` applies to each call (CLI box lists every call: `y` = allow these, `m` = review them one at a time through the per-call prompt, `N` = deny with one comment shared by all), the answer covers that round only (the next one asks again), and a lone request keeps the plain one-call prompt.
|
||||
- **`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). Container args are shape-checked (`tools/chat/shape.py`): a mis-typed string `options`/`fields` (or a non-string `question`) returns an explained error — `options="a, b"` used to iterate characters into a per-character menu. `wait`'s `duration` accepts numeric strings (`"5"` = 5s) and rejects booleans/text/containers.
|
||||
- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by the number a listing shows (render order, TOP group first; the stored `id` stays internal); 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`).
|
||||
@@ -110,18 +111,20 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
||||
- **`net` / `web_search`** (`tools/net/search.py`, `tools/net/bing.py`): the search a DeepSeek provider cannot host, as a local tool. `build_web_search_tool(backends)` closes over the sources named by `provider.search_sources` and asks them **in fallback order** (`search_in_order`): `DeepseekSearchBackend` (one `POST /v1/tools/search` through `lmproto/deepseek/webtools/`, no model round — `deepseek_api_base_url` drops the convention's `/anthropic` and any `/v1` before the client appends its path; 408/429/5xx are retried 0.5s/1.0s, 402 and other 4xx are not) and/or `BingSearchBackend` (Bing's RSS results, snippets included, fetched through the same client as `fetch_local`; opt in with `search_sources = ["deepseek", "bing"]` — the default is `["deepseek"]`). Hits are `SearchHit` from `lmproto/search.py` (DeepSeek's `snippet`, else its `summary` cut to 200 chars); the handler returns one `title — url` entry per hit (plus the snippet when the source has one), and a source that raises or finds nothing is skipped and reported only if every source fails. Registered by the CLI host only when `config.routing.local_web_search` says so: a DeepSeek provider with a non-empty `search_sources`, unless the Anthropic convention already hosts the search (`convention = "anthropic"` **and** `provider_tools` ask for `web_search`) — so `provider_tools = []` brings the local tool back on that surface too. `provider_tools` owns the hosted tool, `search_sources` owns this one. Tags LOCAL|READ_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`.
|
||||
- **`skills`** (`tools/skills.py`): discovery, reading, and writing of skill directories (`plyngent.skills` owns roots, the `SKILL.md` frontmatter subset, the uncached store, and the inside-the-skill path rule). `skill_list` (names, source root, directory, description, shadowed copies, frontmatter notes), `skill_read` (a `SKILL.md` by name, or one bundled file — reuses `read_file` for ranges/line numbers/`truncate_token`), `skill_search` (regex over every visible skill via `regex_files`, hits rebased on `<skill>/<file>`), `skill_create` (new `SKILL.md` + optional `files`), `skill_edit` (replace / `append` / rewrite one file; a rewritten `SKILL.md` is re-parsed and notes a lost description). Reads need no grant (the host publishes the roots as `WorkspacePolicy.static_read`, so `read_file`/`regex_files` see them while writes stay denied); writes go through **`SkillWritePolicy`** on the instance — denylist first, then `SessionState.skill_write_roots` (a `session` answer), then `[skills].allow_write`, then the host's confirm hook (CLI `prompt_skill_write_confirm`: `o`nce/`s`ession/`n`o, timed, default deny, **not** skipped by YOLO); a host with no hook denies. Grants are per **skill directory**; `[skills].enabled = false` drops the tools from the registry (`SKILL_TOOL_NAMES`).
|
||||
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + skills + net (`fetch_local`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `get_truncated`, `listdir`, `glob_paths`, `regex_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `skill_list`/`skill_read`/`skill_search`, `fetch`, `fetch_local`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch_local` accept GET only.
|
||||
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + skills + net (`fetch_local`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `view_image`, `get_truncated`, `listdir`, `glob_paths`, `regex_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `skill_list`/`skill_read`/`skill_search`, `fetch`, `fetch_local`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch_local` accept GET only.
|
||||
|
||||
### Prompting (`prompting.py`)
|
||||
|
||||
Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable backend; non-TTY uses defaults or errors. CLI limit/confirm hooks and chat tools both use this. Async helpers serialize prompts (`run_prompt_async`).
|
||||
|
||||
Three process-wide hooks let a host take over a prompt: `set_prompt_output_hook` (break an open pretty line before the prompt draws), `configure_prompting(pause_factory=…)` (the SIGINT guard around an off-loop read), and `set_prompt_wait_hook` — a prompt that blocks announces itself through `prompt_wait(kind, title, detail)`, which yields the `PromptAnswerSource` the read may consult (`take()` returns `allow` / `deny` / reply text without blocking) or `None` for notify-only. Only the outermost prompt of a nested read is announced; with no hook installed nothing changes. `read_line_with_timeout` accepts a `source` and a `None` deadline, and stdin always wins over a host answer. The CLI's implementation is `cli/notify.py` (`[notify]`, desktop via the optional `desktop-notifier` extra, terminal via OSC 777/9 to `/dev/tty`): a prompt is announced only after `grace_seconds`, the answer is treated like a typed one (never a session grant), and every failure falls through to the next channel instead of raising.
|
||||
|
||||
### CLI (`cli/`)
|
||||
|
||||
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
|
||||
|
||||
- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data when url unset; explicit `url = ":memory:"` kept + warn); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--auto-continue` (auto-raise limits), `--stream/--no-stream`, `--quiet`. Root `--log-level`. Enabled `[mcp]` servers start before the REPL (`_start_mcp_manager`, per-server failures warn on stderr) and are closed on exit (`McpManager.aclose`); each server spawns in its own session/process group (`start_new_session` / `CREATE_NEW_PROCESS_GROUP`), so a terminal Ctrl+C does not kill it.
|
||||
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. `/history` is turn-oriented (`cli/transcript.py`): a turn runs from a user row to the next one, rows carry **display ordinals** (never DB row numbers; restart with the session), the turn's ends (user + final answer) always print in full, `-v` expands every row (one-liners), `-vv` prints full bodies + local rows, `--preview` collapses every row, and `N`/`last [N]` address turns while `--message N` addresses one row. Multiline `"""` … `"""`; `/edit` via `$VISUAL`/`$EDITOR` (blocking only). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack. `/skills` (`list` default) prints the discovered skills with their roots, shadowed copies, and frontmatter notes; `/skills search PATTERN` runs the matching tool handler (the slash path binds the tool context itself); `/skills open NAME` reveals the skill's directory in the system file manager. `/mcp` (`list` default) prints per-server status/tool counts + an `instructions` preview when the server sent usage guidance, + last stderr lines; `/mcp reconnect` re-reads config, restarts every enabled server, and rebuilds the tool registry. `/grants` lists directory-access grants (`revoke <index|all>`; config grants point at the TOML); `/status` includes the grant count and the visible skill count (or `off` when `[skills].enabled` is false).
|
||||
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. `/history` is turn-oriented (`cli/transcript.py`): a turn runs from a user row to the next one, rows carry **display ordinals** (never DB row numbers; restart with the session), the turn's ends (user + final answer) always print in full, `-v` expands every row (one-liners), `-vv` prints full bodies + local rows, `--preview` collapses every row, and `N`/`last [N]` address turns while `--message N` addresses one row. Multiline `"""` … `"""`; `/edit` via `$VISUAL`/`$EDITOR` (blocking only). An `@path` mention in the typed line attaches that file to the turn (`cli/mentions.py`): an image becomes an image part (skipped with a note when the model has no `image_in`), any other file is inlined under a `--- @path ---` header, capped like a tool result with a resumable `truncate_token`; `@"path with spaces"` is the quoted form, and Tab completes the path (quoted when it holds a space, directories end in `/`). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack. `/skills` (`list` default) prints the discovered skills with their roots, shadowed copies, and frontmatter notes; `/skills search PATTERN` runs the matching tool handler (the slash path binds the tool context itself); `/skills open NAME` reveals the skill's directory in the system file manager. `/mcp` (`list` default) prints per-server status/tool counts + an `instructions` preview when the server sent usage guidance, + last stderr lines; `/mcp reconnect` re-reads config, restarts every enabled server, and rebuilds the tool registry. `/grants` lists directory-access grants (`revoke <index|all>`; config grants point at the TOML); `/status` includes the grant count and the visible skill count (or `off` when `[skills].enabled` is false).
|
||||
- `/btw` side questions: forked session; main transcript/DB untouched. `--tools=read` (default) exposes only `READ_ONLY` tools under `read_only_context()` (fetch=GET) with the main session view; `--tools=no` off; `--tools=full` whole-registry clone with fresh session. Tools off: default falls back to no tools; explicit `read`/`full` errors. The model gets an `aside` notice as its last message before the question (unsaved exchange + tool scope), shown to the user as `[notice] aside: …`.
|
||||
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**. A session resumed by a **freshly started process** (`--session` / default latest) also gets a `resume` notice about what a restart drops (PTY sessions, running commands, temp workspaces) — resuming inside a live process pushes nothing.
|
||||
- Host notices (`agent/notices.py`): system-level messages appended as trailing `developer` rows, never system-prompt edits — `Notice.to_message()` for the model, `.summary` for the user via `cli.display.echo_notice` (`--quiet` hides the user line only). Durable hosts persist them (`ChatAgent.push_notice`); a side agent without memory keeps its notice local. `has_notice()` dedupes a session-scoped notice: the `skills` one is pushed once per session (feature enabled + tools on), lists nothing, and makes the model ask with `skill_list` / `skill_search`. Trailing developer messages never end a turn, so `/retry` still sees an interrupted turn behind a notice or checkpoint.
|
||||
|
||||
@@ -173,6 +173,19 @@ max_context_tokens = 200000
|
||||
# enable = ["acme"]
|
||||
```
|
||||
|
||||
```toml
|
||||
# Notifications while a prompt waits on you. See doc/notifications.md.
|
||||
# [notify]
|
||||
# grace_seconds = 5.0 # announce only after this long
|
||||
# channels = ["auto"] # auto | desktop | terminal
|
||||
# actions = true # allow / deny / review buttons + reply field
|
||||
# include_reason = false # off: the reason may name a command or URL
|
||||
|
||||
# Deletes: `delete_path` moves the path to the system trash. See doc/trash.md.
|
||||
# [trash]
|
||||
# enabled = true # false = a delete is final again
|
||||
```
|
||||
|
||||
Per-provider **`timeout`** is passed to the HTTP session for chat/completions, Responses, and `GET /models`. A single number sets one timeout; `{ connect, read }` splits TCP/TLS setup vs idle wait between response bytes (SSE can run longer than `read` while chunks keep arriving). Tool/process timeouts (`run_argv`, PTY, policy confirm) are separate.
|
||||
|
||||
**Thinking strength** (`reasoning_effort`) can be set on `[agent]`, on a provider, or on a single model: the most specific layer wins, per field, so a model may override only the effort or only the budget. `""` sends nothing and lets the provider apply its own default. Each API surface maps the value onto its own wire field — chat completions send `reasoning_effort`, Responses send `reasoning.effort`, and Anthropic has no effort level at all, so it sends `thinking` with the token budget that level maps onto (`minimal` 1024 … `xhigh`/`max` 32768; `none` disables thinking); a thinking block the model refuses is retried once with the shape it takes — adaptive thinking with the level itself as `output_config.effort` (from Claude 4.7 on), or no block at all where thinking cannot be turned off — and the refusal is remembered for the session, so `thinking_budget_tokens` is ignored on such a model. `max` is DeepSeek's own top level on chat completions and clamps to `xhigh` everywhere else. `thinking_budget_tokens` overrides that mapping; either way the request stays valid: the budget never drops below 1024, and when it would swallow the output limit `max_tokens` grows so the answer still has room. DeepSeek chat keeps `thinking.type = "enabled"` unless the effort is `none`.
|
||||
@@ -193,6 +206,10 @@ Third-party **plugins**: install a package that declares `project.entry-points."
|
||||
|
||||
**Skills** (instruction directories with a `SKILL.md`): discovered under `[skills].paths`, our own user skills directory, and other harnesses' skill directories (`~/.claude/skills`, plus `<workspace>/.claude/skills` when `include_project_roots`). A session-start notice (a host notice, never a system-prompt edit) tells the model that the skill tools exist and how to use them — `skill_list` to see what exists, `skill_read` for a `SKILL.md`, `skill_search` for content — and deliberately lists nothing; `skill_create` / `skill_edit` write one behind a timed `once`/`session`/`no` grant (`[skills].allow_write = true` for a standing one; the path denylist still wins and `--yes`/`/yolo` never skips it). Reading needs no grant: skill roots are read-only roots, so `read_file` / `regex_files` see them too while writes stay denied. Details: [doc/skills.md](doc/skills.md).
|
||||
|
||||
**Notifications while a prompt waits**: a turn stops at a confirm, a directory-access request or an `ask_user_*` question, so if you walked away you only find out on your return. After `[notify].grace_seconds` (5s) the prompt is announced — with **Allow once / Deny / Go to terminal** buttons for a confirmation and a reply field for a question, where the platform renders them — and the answer is taken as if typed at the terminal (never as a session grant). The desktop channel needs the optional extra (`pip install 'plyngent[notify]'`); without it the CLI writes an OSC 777 / OSC 9 escape sequence to `/dev/tty`, which every modern terminal turns into a native notification with no dependency at all. Answering at the terminal inside the grace period stays silent, `[notify].include_reason` keeps the reason line (a command, argv or URL) out by default, and no channel can break a prompt: a missing extra or a refusing desktop falls through to the next one. Details: [doc/notifications.md](doc/notifications.md).
|
||||
|
||||
**Deletes go to the trash**: `delete_path` moves the path into the desktop's own trash — the freedesktop trash on Linux, the Finder trash on macOS, the Recycle Bin on Windows — so a delete the agent made on its own can be undone from the file manager you already use. That matters because the tool is YOLO-tagged: under `--yes` / `/yolo on` it runs without a confirmation. Ask for the old behaviour with `permanent=true`, or turn it off for every call with `[trash] enabled = false`. Paths under the system temp directory are scratch and are deleted outright, a trashing that fails (a volume with no usable trash directory, a broken install) leaves the file alone and says so instead of deleting it, and a shell `rm` never uses the trash. Details: [doc/trash.md](doc/trash.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:
|
||||
|
||||
+10
-2
@@ -32,11 +32,19 @@
|
||||
``@tool`` / tags / registry. History stays chat-completions-shaped.
|
||||
- tools: Workspace file/process/VCS/chat/todo/net/skill tools + directory-access
|
||||
grants (`access`); catalog; plugins; instance/session context and views.
|
||||
- prompting: Shared ask/choose/form for CLI and tools.
|
||||
- cli: Click entry, slash registry, REPL, one-shot chat.
|
||||
- prompting: Shared ask/choose/form for CLI and tools; the hooks a host uses
|
||||
to take over a prompt (output break, SIGINT guard, waiting notification).
|
||||
- cli: Click entry, slash registry, REPL, one-shot chat; `notify` announces a
|
||||
prompt that waits on the human.
|
||||
- web: Web service (Phase H; not implemented).
|
||||
|
||||
Plugins (third-party entry points, allowlisted under ``[plugins]``): [plugins.md](./plugins.md).
|
||||
Conversation units (a *round* = one request→response plus its tool batch, a
|
||||
*turn* = a user message to the explicit stop): [turns-and-rounds.md](./turns-and-rounds.md).
|
||||
Images (a turn's pictures, a tool result's pictures, what each wire surface
|
||||
takes): [images.md](./images.md).
|
||||
Prompt notifications (a prompt that waits on the human, and how it can be
|
||||
answered from the notification): [notifications.md](./notifications.md).
|
||||
Deletes (a `delete_path` call moves the path into the system trash, and what
|
||||
that does not cover): [trash.md](./trash.md).
|
||||
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# Images
|
||||
|
||||
A model that takes pictures can look at a screenshot, a chart, or a diagram
|
||||
instead of reading a description of it. plyngent carries images as content
|
||||
parts on messages; each wire surface lifts them into its own shape.
|
||||
|
||||
## Shape
|
||||
|
||||
`lmproto/openai_compatible/model.py`:
|
||||
|
||||
| Type | Meaning |
|
||||
|---|---|
|
||||
| `ChatImageUrl(url, detail)` | one picture: an `http(s)` link or an inline `data:` URL |
|
||||
| `ChatImagePart(image_url)` | that payload as a `{"type": "image_url"}` block |
|
||||
| `ChatTextPart(text)` | the text block beside images |
|
||||
| `UserChatMessage.content` | `str`, or `list[ChatTextPart \| ChatImagePart]` when the turn carries pictures |
|
||||
| `ToolChatMessage.images` | the pictures a tool returned, beside its text `content` |
|
||||
|
||||
Prose-only readers go through `content_to_text`, which renders each image as
|
||||
`[image]` (summaries, transcripts, exports, the retry preview).
|
||||
|
||||
`agent/images.py` owns the bytes: it sniffs the format from the file's content
|
||||
(JPEG `ff d8 ff`, PNG, GIF, WebP `RIFF….WEBP`), builds the data URL, and caps an
|
||||
inline picture at `MAX_INLINE_IMAGE_BYTES` (8 MiB) — far below what a provider
|
||||
accepts, because the message is re-sent every round and lives in the session's
|
||||
JSON message column.
|
||||
|
||||
## Per surface
|
||||
|
||||
| Surface | A user turn's images | A tool result's images |
|
||||
|---|---|---|
|
||||
| chat completions (`compat.chat_messages_with_images`) | already content blocks | a `tool` message may only carry text, so the images move into the user turn emitted right after the tool run |
|
||||
| Anthropic `/messages` (`messages_bridge`) | `image` blocks beside the text block | inside the `tool_result` block's content, beside a text block |
|
||||
| Responses (`responses_bridge`) | `input_text` + `input_image` parts | in the `function_call_output` item's `output` array |
|
||||
|
||||
A chat-completions request drops the parts of a message it cannot express; no
|
||||
history is rewritten (the stored message keeps its own images).
|
||||
|
||||
## Limits (provider side)
|
||||
|
||||
| Limit | Value |
|
||||
|---|---|
|
||||
| Formats | JPEG, PNG, GIF, WebP (sniffed from bytes, never the file name) |
|
||||
| Request body | 48 MiB |
|
||||
| One image, inline or URL | 32 MiB |
|
||||
| One image, Files API `file_id` | 64 MiB |
|
||||
| Images per request | 600 |
|
||||
| Image side | 8192 px, or 4096 px when a request holds 15+ images |
|
||||
| External URL | 8192 characters, downloaded within 60 s |
|
||||
| Tokens per image | at most 1024 (a picture is resized to ~1300x1300 px, and one below ~544x544 is scaled up) |
|
||||
|
||||
Images belong to **user** messages only: a picture in a system or assistant
|
||||
message is a 400 (`Image in assistant message is unsupported`). A tool result
|
||||
lives inside a user turn, which is why it can carry one.
|
||||
|
||||
## Verified against the DeepSeek API
|
||||
|
||||
The 2026-10-17 docs plus a live probe (`deepseek-flash`):
|
||||
|
||||
- the same picture is accepted on all three surfaces, and the usage is identical;
|
||||
- an image inside `tool_result` content works, and the model reads it — the
|
||||
round that asked for a screenshot got the layout, the colours and the pixel
|
||||
span back;
|
||||
- an image inside `function_call_output` works too (the round needs its
|
||||
`reasoning` item, which `responses_bridge` already sends);
|
||||
- `detail: "low"` measured 3.4x cheaper than `original` on a 1200x900 picture
|
||||
(240 vs 708 prompt tokens) with no loss on "which colour where"; the floor is
|
||||
210 tokens even for a tiny picture, because small images are scaled up;
|
||||
- re-sending a picture in a later round hits the provider's prefix cache
|
||||
(`cache_read_input_tokens`), so the "history is paid for again" cost is not
|
||||
the worst case;
|
||||
- `/anthropic/messages` and `/anthropic/v1/messages` both answer, so the
|
||||
client's path stays as it is.
|
||||
|
||||
## Attaching one
|
||||
|
||||
Type `@path` in the line — no command, no separate step:
|
||||
|
||||
```
|
||||
what is wrong with this layout? @shots/broken.png
|
||||
```
|
||||
|
||||
`cli/mentions.py` reads the token (whitespace-delimited, `@"path with spaces"`
|
||||
for names that hold one; an e-mail address is not a mention), resolves it against
|
||||
the working directory, checks the path denylist, and attaches it. An image
|
||||
becomes an image part; any other file is appended as text under a
|
||||
`--- @path ---` header, capped like a tool result (its `truncate_token` keeps the
|
||||
remainder resumable). A problem — a missing file, a denylisted path, a picture a
|
||||
text-only model cannot take — prints a note and leaves the turn going.
|
||||
|
||||
Tab completes the path after `@` (directories end in `/`, a name with a space is
|
||||
completed in its quoted form).
|
||||
|
||||
## Opting in
|
||||
|
||||
`[providers.<name>.models."<model>"].image_in` is the gate — an `@image` mention
|
||||
is skipped, with a note naming the key, on a model that does not set it, and the
|
||||
default catalogs mark what takes pictures (`deepseek-flash` yes,
|
||||
`deepseek-v4-pro` no, OpenAI's defaults yes). A model missing from the catalog
|
||||
has to be declared: the flag is an explicit opt-in.
|
||||
|
||||
## Not implemented
|
||||
|
||||
- **Files API** (`POST /files` → `file_id`): it lifts the 32 MiB per-image cap
|
||||
and avoids re-uploading the same bytes, but it does not change the token bill
|
||||
(the model still processes the picture in every request that carries it), so a
|
||||
local 8 MiB cap plus resizing covers the common case.
|
||||
- **Image output**: the API documents none.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Prompt notifications
|
||||
|
||||
A turn stops while a prompt waits on the human. If you switched to another
|
||||
window, the wait is invisible until you come back — so the CLI announces it.
|
||||
|
||||
A prompt is announced only after `grace_seconds` of waiting (5s by default), so
|
||||
answering at the terminal stays silent. Bursts are throttled by
|
||||
`min_interval_seconds`, and the announcement says what is being asked, not what
|
||||
it would run: the one-line reason can name a command, argv or URL, and a
|
||||
notification outlives the prompt (and may be mirrored to a lock screen), so it
|
||||
travels only with `include_reason = true`.
|
||||
|
||||
## Configuration
|
||||
|
||||
```toml
|
||||
[notify]
|
||||
enabled = true
|
||||
grace_seconds = 5.0
|
||||
min_interval_seconds = 10.0
|
||||
channels = ["auto"] # auto | desktop | terminal
|
||||
actions = true # offer the answer, not just the news
|
||||
include_reason = false
|
||||
protocol = "" # terminal protocol override: osc777 / osc9 / bell
|
||||
```
|
||||
|
||||
`channels = ["auto"]` tries the desktop first and falls back to the terminal;
|
||||
`["desktop"]` and `["terminal"]` pin one of them. Notifications are only sent
|
||||
from an interactive run — a one-shot `plyngent "…"` never waits on a human, and
|
||||
a prompt answered inside the grace period is never announced at all.
|
||||
|
||||
## What is answered where
|
||||
|
||||
| Prompt | Notification offers |
|
||||
|---|---|
|
||||
| tool confirm (single / a round's batch) | Allow once / Deny / Go to terminal |
|
||||
| command denylist, `fetch_local` private host | same three |
|
||||
| directory-access request, skill write | same three |
|
||||
| `ask_user_line` | a reply field — the answer *is* the text you type |
|
||||
| `ask_user_choice`, `ask_user_form`, PTY secrets | nothing (terminal only) |
|
||||
| limit-continue | nothing (terminal only) |
|
||||
|
||||
An answer read from the notification is treated exactly like a typed one —
|
||||
`allow` is the `y` the prompt accepts, `deny` is `n` — and it never records a
|
||||
session grant: the button means "this once". "Go to terminal" withdraws the
|
||||
notification and leaves the prompt waiting. A secret is never asked for in a
|
||||
notification, and never echoed into one.
|
||||
|
||||
## Desktop channel
|
||||
|
||||
The desktop channel needs the optional extra:
|
||||
|
||||
```console
|
||||
pip install 'plyngent[notify]'
|
||||
```
|
||||
|
||||
It wraps `desktop-notifier`, which talks to `org.freedesktop.Notifications` on
|
||||
Linux, Notification Center on macOS and WinRT on Windows. Buttons need a
|
||||
notification server that renders them (KDE Plasma and GNOME Shell do; a bare
|
||||
`dunst` may not, and then the notification is text-only without any error). The
|
||||
reply field is KDE-only in practice: it is the `inline-reply` capability plus the
|
||||
`x-kde-reply-submit-button-text` hint, which only KNotifications implements.
|
||||
|
||||
Notifications are shown from the library's async API on a loop that runs in a
|
||||
daemon thread, because callbacks only fire while the loop that owns the
|
||||
notification is running.
|
||||
|
||||
## Terminal channel
|
||||
|
||||
No dependency and no extra: the CLI writes an escape sequence to `/dev/tty`
|
||||
(never stdout, so a redirect cannot swallow it) and the terminal turns it into a
|
||||
native notification.
|
||||
|
||||
| Protocol | Sequence | Terminals |
|
||||
|---|---|---|
|
||||
| OSC 777 | `ESC ] 777 ; notify ; title ; body` | Ghostty, WezTerm, Warp, Contour, VTE (GNOME Terminal, Tilix, Terminator), rxvt, Windows Terminal |
|
||||
| OSC 9 | `ESC ] 9 ; title: body` | iTerm2, Apple Terminal, ConEmu |
|
||||
| bell | `BEL` | everything else |
|
||||
|
||||
Detection reads the environment only (`TERM_PROGRAM`, `TERM`, `WT_SESSION`,
|
||||
`VTE_VERSION`), so it never blocks and never touches the input stream;
|
||||
`protocol` pins it. Inside tmux the sequence is wrapped in DCS passthrough —
|
||||
tmux needs `set -g allow-passthrough on` — and inside GNU Screen in a Screen
|
||||
DCS. kitty speaks OSC 99 only, which is not implemented, so it keeps the bell;
|
||||
install the extra to reach it through the desktop instead.
|
||||
|
||||
## Failure is always silent
|
||||
|
||||
A missing extra, a desktop without a notification server, a refused write: each
|
||||
falls through to the next channel and never raises into the prompt. There is no
|
||||
sound, no remote push and no notification when a turn merely ends — only when a
|
||||
prompt is waiting for you.
|
||||
+4
-2
@@ -48,8 +48,10 @@ Notes:
|
||||
- Session-only helpers may add **`PUBLIC`** and **`SESSION_STATE`** when they
|
||||
only touch session data and host-approved APIs (see plans under `doc/plans/`).
|
||||
- Optional soft-confirm bits: **`YOLO`** (eligible for YOLO auto-approve),
|
||||
**`TRUSTABLE`** (grant once per session after approve). Hard denylists are
|
||||
never YOLO-skipped.
|
||||
**`TRUSTABLE`** (grant once per session after approve). The host's danger
|
||||
classifier may refuse that skip for one call by returning
|
||||
`DangerReason(text, yolo_skip=False)` — plyngent does so for a command that
|
||||
escalates privileges. Hard denylists are never YOLO-skipped.
|
||||
- Optional host-policy bit: **`READ_ONLY`** marks tools that never mutate host
|
||||
state — they become eligible for read-only contexts such as
|
||||
`/btw --tools=read` side turns or planning. Tools that only sometimes mutate
|
||||
|
||||
@@ -42,6 +42,35 @@
|
||||
# include_project_roots = true
|
||||
# allow_write = false
|
||||
|
||||
# Notifications while a prompt waits on the human: a tool confirmation, a
|
||||
# directory-access grant, a skill write, an ``ask_user_*`` question, or the
|
||||
# limit-continue prompt. A prompt is announced only after `grace_seconds` of
|
||||
# waiting, so one you answer at the terminal stays silent; `min_interval_seconds`
|
||||
# throttles bursts.
|
||||
# channels: "auto" tries the desktop first (needs the `notify` extra:
|
||||
# `pip install plyngent[notify]`) and falls back to a terminal escape sequence;
|
||||
# "desktop" / "terminal" pin one of them.
|
||||
# `actions` offers allow / deny / review as notification buttons; where the
|
||||
# platform has none the notification stays text-only.
|
||||
# `include_reason` adds the one-line reason — it can name a command or a URL, so
|
||||
# it stays off by default (a notification may be mirrored to a lock screen).
|
||||
# [notify]
|
||||
# enabled = true
|
||||
# grace_seconds = 5.0
|
||||
# min_interval_seconds = 10.0
|
||||
# channels = ["auto"]
|
||||
# actions = true
|
||||
# include_reason = false
|
||||
# protocol = "" # terminal protocol: "" = detect, or osc777 / osc9 / bell
|
||||
|
||||
# Deletes. `delete_path` moves the path into the desktop's trash (freedesktop
|
||||
# trash / Finder trash / Recycle Bin) so it can be restored from the file
|
||||
# manager; `permanent=true` on the call removes it for good, and paths under the
|
||||
# system temp dir are always deleted outright. `enabled = false` restores the
|
||||
# old "a delete is final" behaviour for every call. See doc/trash.md.
|
||||
# [trash]
|
||||
# enabled = true
|
||||
|
||||
# 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.
|
||||
@@ -124,7 +153,9 @@ access_key_or_token = "sk-replace-me"
|
||||
# timeout = { connect = 10, read = 600 }
|
||||
|
||||
[providers.openai_compat.models]
|
||||
"gpt-4o-mini" = { text = true, cost_factor = 1.0 }
|
||||
# ``image_in`` is the opt-in for a model that reads pictures (JPEG, PNG, GIF,
|
||||
# WebP); an ``@image`` mention is skipped without it. See doc/images.md.
|
||||
"gpt-4o-mini" = { text = true, image_in = true, cost_factor = 1.0 }
|
||||
|
||||
# --- OpenAI platform (Responses API; tag is "openai") ---
|
||||
# If ``preset`` is omitted, it defaults to "openai".
|
||||
@@ -202,5 +233,7 @@ access_key_or_token = "sk-replace-me"
|
||||
# search_sources = ["deepseek"]
|
||||
#
|
||||
# [providers.deepseek.models]
|
||||
# "deepseek-flash" = { text = true, convention = "responses" }
|
||||
# Defaults: "deepseek-flash" takes pictures (image_in = true), "deepseek-v4-pro"
|
||||
# is text-only.
|
||||
# "deepseek-flash" = { text = true, image_in = true, convention = "responses" }
|
||||
# "deepseek-v4-pro" = { text = true, reasoning_effort = "max" }
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
# Deletes and the system trash
|
||||
|
||||
`delete_path` is the agent's only way to remove a path, and it is tagged
|
||||
`YOLO`: under `--yes` / `/yolo on` it runs with no confirmation at all. So the
|
||||
default has to be recoverable — a delete moves the path into the desktop's own
|
||||
trash, where the file manager the human already uses can put it back.
|
||||
|
||||
```text
|
||||
deleted file notes.md (moved to system trash)
|
||||
deleted directory build (moved to system trash)
|
||||
deleted file notes.md # permanent=true
|
||||
```
|
||||
|
||||
`permanent=true` is the old behaviour, chosen on purpose. `[trash] enabled =
|
||||
false` makes every delete that way.
|
||||
|
||||
## Where a path goes
|
||||
|
||||
| Platform | Trash |
|
||||
|---|---|
|
||||
| Linux / BSD | The freedesktop trash specification: `$XDG_DATA_HOME/Trash` (`~/.local/share/Trash` by default), or a per-volume trash when the path is on another filesystem |
|
||||
| macOS | The Finder's trash (`FSMoveObjectToTrashSync`; `NSFileManager` when pyobjc is installed) |
|
||||
| Windows | The Recycle Bin (`IFileOperation` with pywin32, else `SHFileOperation`) |
|
||||
|
||||
The platform work is [send2trash](https://github.com/arsenetar/send2trash), a
|
||||
required dependency because a missing safety net that fails silently is worse
|
||||
than no safety net. It is pure Python over `ctypes` — nothing to compile, no
|
||||
runtime dependencies.
|
||||
|
||||
Nothing is renamed away piecemeal: the whole path moves into the trash in one
|
||||
`rename`, so a large directory costs nothing to delete (across filesystems,
|
||||
where the volume has no trash of its own, the fallback is a move into the home
|
||||
trash, which does copy). A path trashed twice keeps both copies (`dup.txt`,
|
||||
then `dup 1.txt`).
|
||||
|
||||
## What is deleted outright
|
||||
|
||||
| Case | Why |
|
||||
|---|---|
|
||||
| `permanent=true` | The call said so |
|
||||
| `[trash] enabled = false` | The host said so |
|
||||
| A path under the OS temporary directory | Scratch space: nobody restores it, and a trash on the `/tmp` volume would only collect it. The same rule that `new_temporary_workspace` cleans up with |
|
||||
| `new_temporary_workspace` cleanup at chat exit | Same reason |
|
||||
| `copy_path` / `move_path` with `overwrite=true` | The destination is being replaced by an explicit call; the source content is the point of the operation |
|
||||
|
||||
## When the trash is unavailable
|
||||
|
||||
A trashing that fails is reported, never downgraded to a deletion — the human
|
||||
was told the path is recoverable, so guessing would be worse than stopping:
|
||||
|
||||
```text
|
||||
error: cannot move to trash: Permission denied (use permanent=true)
|
||||
error: cannot move to trash: send2trash is not installed (use permanent=true)
|
||||
```
|
||||
|
||||
The usual causes are a path on a filesystem with no `.Trash` directory that the
|
||||
user cannot create one on (network mounts, some FUSE filesystems, other
|
||||
people's volumes), a parent directory without write permission, and a broken
|
||||
install. In every case the path is untouched and the error names the way
|
||||
forward.
|
||||
|
||||
## Restoring
|
||||
|
||||
Restore from the desktop's own trash — the file manager's "Trash" / "Recycle
|
||||
Bin", which is where a human would look anyway. plyngent does not offer a
|
||||
`restore` tool: `send2trash` does not report where a path landed, and the
|
||||
platform trash formats differ. A delete therefore reports that the path is in
|
||||
the trash, not where.
|
||||
|
||||
## What the trash does not cover
|
||||
|
||||
- **A shell command.** `run_argv(["rm", "-rf", …])`, a script, or anything run
|
||||
through a PTY reaches the filesystem directly; the command denylist and the
|
||||
soft confirm of interpreter runs are the only gates there, and `rm` does not
|
||||
use the trash.
|
||||
- **Plugins and MCP servers.** They bring their own handlers.
|
||||
- **Writes.** `write_file` with `overwrite=true` replaces a file's contents in
|
||||
place — it never removes a path. Only `delete_path`, the `copy_path` /
|
||||
`move_path` overwrite, and the temporary-workspace cleanup remove paths.
|
||||
|
||||
## Privacy
|
||||
|
||||
A trashed path is still on disk until the trash is emptied — that is the point
|
||||
of it. `[agent].path_denylist` still runs first, so a denied path is never
|
||||
trashed either, and a trashed file has left the workspace (it is no longer in a
|
||||
`git status`, and no longer readable by a path tool).
|
||||
|
||||
## Platform limits worth knowing
|
||||
|
||||
- **Windows**: the shell deletes some things outright even when the call asked
|
||||
for the Recycle Bin — empty folders (a folder only reaches the bin with
|
||||
content), files above the bin's size limit for that drive, and paths on
|
||||
network shares. That is Windows' behaviour, not a fallback here.
|
||||
- **Linux**: a path on another volume needs a trash directory there
|
||||
(`volume/.Trash/$UID` with the sticky bit, or a creatable `volume/.Trash-$UID`).
|
||||
Without one the file is moved into the home trash instead, which crosses a
|
||||
filesystem boundary and therefore copies.
|
||||
- **macOS**: without pyobjc the library calls the older
|
||||
`FSMoveObjectToTrashSync`; installing `send2trash[nativeLib]` switches it to
|
||||
`NSFileManager`. Both trash; neither needs anything from plyngent.
|
||||
@@ -2,10 +2,10 @@
|
||||
# It is not intended for manual editing.
|
||||
|
||||
[metadata]
|
||||
groups = ["default", "dev"]
|
||||
groups = ["default", "dev", "notify"]
|
||||
strategy = ["inherit_metadata"]
|
||||
lock_version = "4.5.1"
|
||||
content_hash = "sha256:b69c90f70f1118ebb9080c3e0190e8b4d85dd4e5999466b617430399d0ab3125"
|
||||
content_hash = "sha256:6b62075d8b95d83a7d5d6c99c073db457750af4c8a4e988adbef8799f6f4167e"
|
||||
|
||||
[[metadata.targets]]
|
||||
requires_python = "~=3.14"
|
||||
@@ -50,6 +50,20 @@ files = [
|
||||
{file = "basedpyright-1.40.2.tar.gz", hash = "sha256:d4db36d095230379acc66c7e5fba7e4b50ca902f6e803d696782dd031cb8d7d7"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bidict"
|
||||
version = "0.24.1"
|
||||
requires_python = ">=3.11"
|
||||
summary = "The bidirectional mapping library for Python."
|
||||
groups = ["notify"]
|
||||
dependencies = [
|
||||
"typing-extensions>=4.0; python_full_version < \"3.12\"",
|
||||
]
|
||||
files = [
|
||||
{file = "bidict-0.24.1-py3-none-any.whl", hash = "sha256:fd3eaa737917d8a14f4baa391670c433c4e3f6f5fd2cd99d4bf436437f432364"},
|
||||
{file = "bidict-0.24.1.tar.gz", hash = "sha256:4dca6c17f0b01700e9f24359daa5ebabf7be022d99f4cb2a257b6af2a5076c88"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.5.2"
|
||||
@@ -169,6 +183,69 @@ files = [
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbus-fast"
|
||||
version = "5.2.0"
|
||||
requires_python = ">=3.11"
|
||||
summary = "A faster version of dbus-next"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"linux\""
|
||||
files = [
|
||||
{file = "dbus_fast-5.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff55fddbc7567cb39f10b5d7e9bed1f2b19c88fc1d18c86fa67ca06becfe8fe7"},
|
||||
{file = "dbus_fast-5.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a772708d25c11e980642781f603882e3dc51b5767be19075ffc5a484c4d3411"},
|
||||
{file = "dbus_fast-5.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c66b094e96c221b877ccd6627bc3b9d808ac8317a8f6adc1cb2a0223e7d64e2"},
|
||||
{file = "dbus_fast-5.2.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:79b842eb42f439fabd47db9deb7846d849933eb864fc53373d355c63f850eaa6"},
|
||||
{file = "dbus_fast-5.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:788861134ac1794d44a03970fc817896b4bb35247353eeb13c363e238f7d4474"},
|
||||
{file = "dbus_fast-5.2.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:352e4cc8dbc608e297a73784857a8f9841d3a221b10e4b0f6a1b4b5168456e51"},
|
||||
{file = "dbus_fast-5.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fc04ca465f9d9847aa4273efe85da8fed82988004f1002b833df788f48fc0ecd"},
|
||||
{file = "dbus_fast-5.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bcc1514888cbb82533777f3855e06135e5e8526ca6d7f75687b8b1fcf140ce33"},
|
||||
{file = "dbus_fast-5.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa260884e2df72d584ffec2d5d2f90ea0d624db8326ff0bea33b59f8998a09f2"},
|
||||
{file = "dbus_fast-5.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc5845602cd734e01bcee84fc2ff08642987d95d42edb434d048103905d3173f"},
|
||||
{file = "dbus_fast-5.2.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:288111b8d920b5ab445c2d9e4f13cd8521fe5233efe191c4749dd8fd07c5beb9"},
|
||||
{file = "dbus_fast-5.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d828828f879c0536981c1eaf2d4c6fa65fd30354cecb1e16a158a9cda36a827c"},
|
||||
{file = "dbus_fast-5.2.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c4e7f48961e7c85540086458be0c5ca6ae6272e327c15907bd7d1e777ab2ace"},
|
||||
{file = "dbus_fast-5.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a0d506adfcbd5451e23ec2b645437ccf419e9ed7ad1f6f82b622d2a292fd23e5"},
|
||||
{file = "dbus_fast-5.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:90da44436de6f5773637216159b7f0c5b53aa357c54c38593b12ff3ed3a4e649"},
|
||||
{file = "dbus_fast-5.2.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1aad5984b9724f438a2ccd5e3df15723aece0d248b309576744345a04eee948a"},
|
||||
{file = "dbus_fast-5.2.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dbe4982d86e93fe285c695c0808601e7187f01501df77c2342d4132c11bcac17"},
|
||||
{file = "dbus_fast-5.2.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d01ae4246b3b503b529be3f4ad3660d92687b5d0f085683d2ef48ec3247d5133"},
|
||||
{file = "dbus_fast-5.2.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f4a47be94f369cca2308645345df8a0949e9139f9b0f6e64fbd11945924b13b7"},
|
||||
{file = "dbus_fast-5.2.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:594f755fe172c76dd1a7f6558504244a0713da4ce07d9cf9abb5db80372a5d4f"},
|
||||
{file = "dbus_fast-5.2.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c0ca312d8643f1f358f9fd96d2ccaf8dccd01d0c14c08c20e3f1686aa198231d"},
|
||||
{file = "dbus_fast-5.2.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:679f2daef2b88d6129845013403b32d184ecf90805fce247340469bd5f495943"},
|
||||
{file = "dbus_fast-5.2.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b8a6c22657740523f8d16e4d373925a408c7dc30dfd4935ea21939c042510c"},
|
||||
{file = "dbus_fast-5.2.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22ac2df864dd0532f3d797f21118341e7520d6b36ac68a327eac6291624fb2b"},
|
||||
{file = "dbus_fast-5.2.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d91ce3cd74b3b8a1518afca3ceb90ab7b280a53e9c50a257453b83e48c4b19c"},
|
||||
{file = "dbus_fast-5.2.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:cc171f8b0728626eba19ac5893cbf1a5a813120e6fd0440168ba013f941abeb8"},
|
||||
{file = "dbus_fast-5.2.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:5e8d93ca1b3d344c7ff5c4959e6d8ac2c6a0e794aef9d1606177647d537b7e99"},
|
||||
{file = "dbus_fast-5.2.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e6f32672a446284b0d381349c91f6602356a4c017c1604fccbcc02347496be92"},
|
||||
{file = "dbus_fast-5.2.0.tar.gz", hash = "sha256:a4a5dddc04b1ade5eb7650d791e2f6fb7c1334595593473914e78a2526ecddda"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "desktop-notifier"
|
||||
version = "6.2.0"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Python library for cross-platform desktop notifications"
|
||||
groups = ["notify"]
|
||||
dependencies = [
|
||||
"bidict",
|
||||
"dbus-fast; sys_platform == \"linux\"",
|
||||
"packaging",
|
||||
"rubicon-objc>=0.5; sys_platform == \"darwin\"",
|
||||
"typing-extensions",
|
||||
"winrt-runtime>=3.1; sys_platform == \"win32\"",
|
||||
"winrt-windows-applicationmodel-core>=3.1; sys_platform == \"win32\"",
|
||||
"winrt-windows-data-xml-dom>=3.1; sys_platform == \"win32\"",
|
||||
"winrt-windows-foundation-collections>=3.1; sys_platform == \"win32\"",
|
||||
"winrt-windows-foundation>=3.1; sys_platform == \"win32\"",
|
||||
"winrt-windows-ui-notifications>=3.1; sys_platform == \"win32\"",
|
||||
]
|
||||
files = [
|
||||
{file = "desktop_notifier-6.2.0-py3-none-any.whl", hash = "sha256:193b0885e694a99ae22f72234c91afbac633074295b44183bf9716dd4077b8f3"},
|
||||
{file = "desktop_notifier-6.2.0.tar.gz", hash = "sha256:528167b691ce40031fa92f67c9f452b7be29846613e19d11ec0c49cb5242d338"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.5.6"
|
||||
@@ -414,7 +491,7 @@ name = "packaging"
|
||||
version = "26.3"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Core utilities for Python packages"
|
||||
groups = ["dev"]
|
||||
groups = ["dev", "notify"]
|
||||
files = [
|
||||
{file = "packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c"},
|
||||
{file = "packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79"},
|
||||
@@ -422,13 +499,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.13.3"
|
||||
version = "4.13.5"
|
||||
requires_python = ">=3.11"
|
||||
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||
groups = ["default"]
|
||||
files = [
|
||||
{file = "platformdirs-4.13.3-py3-none-any.whl", hash = "sha256:f6ad7f447f24f8a3b82cce5976387428bff894a0eca6c3488f4a17f153c130c4"},
|
||||
{file = "platformdirs-4.13.3.tar.gz", hash = "sha256:5e567f664eb087ab8521c0179cd8d1bd60857d271136567a39719e28e2d383ce"},
|
||||
{file = "platformdirs-4.13.5-py3-none-any.whl", hash = "sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e"},
|
||||
{file = "platformdirs-4.13.5.tar.gz", hash = "sha256:05831adf87d392760f4b5c3a0a95579ab5a10b18b9b9bd43680dcb47544788a0"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -568,6 +645,18 @@ files = [
|
||||
{file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rubicon-objc"
|
||||
version = "0.5.7"
|
||||
requires_python = ">=3.11"
|
||||
summary = "A bridge between an Objective C runtime environment and Python."
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"darwin\""
|
||||
files = [
|
||||
{file = "rubicon_objc-0.5.7-py3-none-any.whl", hash = "sha256:3dc76bc29f300ee6b9a4f408748099f70f8472ca59fe70cca42dfa1f2f1bfe3f"},
|
||||
{file = "rubicon_objc-0.5.7.tar.gz", hash = "sha256:ed2a56284a18ceb6370276069e8aab685b5465bbb457a5f13e09e37d0618703d"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.10"
|
||||
@@ -595,9 +684,20 @@ files = [
|
||||
{file = "ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "send2trash"
|
||||
version = "2.1.0"
|
||||
requires_python = ">=3.8"
|
||||
summary = "Send file to trash natively under Mac OS X, Windows and Linux"
|
||||
groups = ["default"]
|
||||
files = [
|
||||
{file = "send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c"},
|
||||
{file = "send2trash-2.1.0.tar.gz", hash = "sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.1.4"
|
||||
version = "2.1.5"
|
||||
requires_python = ">=3.11"
|
||||
summary = "Database Abstraction Library"
|
||||
groups = ["default"]
|
||||
@@ -605,48 +705,48 @@ dependencies = [
|
||||
"typing-extensions>=4.6.0",
|
||||
]
|
||||
files = [
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a2f6164c0527cd8fc4cea79a5c9d8369ffee417b8ba444a42342f36b91deb75"},
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6929a11ad26a91a4efd891c1252b373c2e88f056910b83ec6030ed3f2cbcb734"},
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14528d37d7d46a92f2a483f188f7fecd86cdd789254a0412b960c9fc5e9efd6d"},
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d2cb669c6bd1f19caf51db6e3c4fdd4cbb76f9db3ef81c3aeb5e288d9bae101b"},
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:63dc25b21fd9a41dc09b7aada4b3b0d97cf4b6414f74bced6ac45326bc799ac9"},
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:308f96d24e773d64609a2a0d1161a068f9f6e9165523bc4e07aa9c45f0c4213f"},
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93b9416b9011a3b7689a933e04ac9f61d15686b6cb1948ebc1f41467153116c3"},
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314-win32.whl", hash = "sha256:89db94855287fdac98d74595cf13ea59fbffa608d6400ff972b0fd4c036d873f"},
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314-win_amd64.whl", hash = "sha256:080f8d853aac5bb5620f0ae6f46527397cf18dce0ec2b478b478469ef3cae2c4"},
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314-win_arm64.whl", hash = "sha256:64d41be1dd88f184de1931f0173f4827122a1b49fd1150656641200c0bdf640c"},
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84272f329c15081a1e09b4a7261118b4e8a547f43e00fca98e55bbdf19eff3be"},
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b3f58bd26fc010ea28976d401845e4e6ce02e1b7c0288b3ea9c9a3c396f0bcc"},
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82d728075d42bd457d09655cf22e99d772a648c6f67e86743a4f05b7d063ca18"},
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0970394ec5d9e397aafc5bc5fa2b7f8b58cb191f2703006b19a96ef4bf00b8d9"},
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6005f2f5fcd67fdd721446128e6a2a1d18f77387a604fbd26b0006a086b33096"},
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0e01a3e199ae219381c4889993c5584b1b905fffe6830f639adb6770036a8913"},
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:22129e7d00ac66b291840c4dc83a9c497456ab5bffa682dcbfdc2356f9e49e5a"},
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314t-win32.whl", hash = "sha256:bc33d3e59d4e84b8866cc9ba13732585e37212dbe3542cb09f232682b36f47a5"},
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314t-win_amd64.whl", hash = "sha256:346d144e8912ae087b10d3c2081657cb634728600693eee6dbb71d7eb4768101"},
|
||||
{file = "sqlalchemy-2.1.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3e5de57c71b3460e2ca6137e82cd3cb8c9f711f301f50d5c77156fdb9c822999"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:418786f05387ddb66ee683a1d016c5a8d9bf7be921e6ee8f285c7b6ac961a731"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:283914efed30e4d44301e36ac90ad048570538b8a70f072fe01578d9b205d09c"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d2eacdbeb990b80235763860923c60a8393745b66f7149a734980c65896da72"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e43fca5fdd5f34a3f8c54107a3648d3139de8bbf596a189f3f0de94bd84949bb"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2e1b5343d315b10a4a71da481729f66f830a561595e02b61e8a5a65d658325ac"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:42c37c06adcecf444e8c981f7e9237a41bdd445c83da0df9e08b4ad958becbbc"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:bab7f51d38766d6a64da2b41976f1b3f9cc2ff37d3f2f63bdbac876199f3a48e"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315-win32.whl", hash = "sha256:1541ba5bf0f232cd61f9ef3df78c93977c72ba6031506a0e6d057b2a3ddb76e9"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315-win_amd64.whl", hash = "sha256:596a95611c217cb19c21f02f43c637cb507cab71dcf0467c5c7d98fcdd703007"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315-win_arm64.whl", hash = "sha256:0d1ca95e42ce3c18818f170b741d30a33b292c6f6b9a202ffd717e28fc99b8c7"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0f672ed6972164fec94a8f0b21dcf8545080d0727866335fb8adf9f4764ce6ec"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72e3fa41d1fdab87d4e88bbdd69c9522e2795549fbe7b07bcf4ae9ec175f4b11"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cb2cb98d056e63e353ed697750004e07c79b054d73059ba3184ca3bb07296bea"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d66fdcc5506e0f8bb8d3f4f95125220a7cd6c46e8b1762750f01e9639973dd8"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:81f802c96dbf96e59c6982fa1b87da7868920fb0c27b9b81e560a62f57c2ccfb"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:acf8982c70471a68aa90d1aba08b48860c55b3357ec84ccb0f09368ead2ce099"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:778094c83e36c430756a7e1a1ac66fc3cffb2c6a1067958fe6b920abcec7bc5a"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315t-win32.whl", hash = "sha256:963348422b22f760e9462e56bc32bf4d95d224cc5b8c79a3c6e3b786d3d2a2b2"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315t-win_amd64.whl", hash = "sha256:fba3500e170d25f581e053009edeb0b158116084d91d465de218718d336b67c3"},
|
||||
{file = "sqlalchemy-2.1.4-cp315-cp315t-win_arm64.whl", hash = "sha256:0a9a464bc360856b7ea9bf8aa26aab92ca115dd08149cb0e004063d5db13584b"},
|
||||
{file = "sqlalchemy-2.1.4-py3-none-any.whl", hash = "sha256:0b96edcc2cd60fe1e35f67a46f4eb076e57297841b9eae949ac5f196593f00a7"},
|
||||
{file = "sqlalchemy-2.1.4.tar.gz", hash = "sha256:7bd7ad604487daa7eab8716471c29a7185f17b5287ce73bb7bc79fea050d8cfd"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5541a110ac4bb6377c785aea1b8f0b4d6658ed0ca5061ed1115d0725062b026b"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f029b2f7114623c03f762596f313515a2358004c44eb18924a15940f2e27d30f"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc44dcc80e89a006af415edffc7409b1f2976a4597065449c166987f95801ab4"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:72ddd7a8ec187d02de0ef316a15aaf381ddcc594958247b02a3dcdb4c42f0b02"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f5173a6e60a15090f9e2348a8ca94cc13de72796c16733f3b62fbb22acba07b8"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:09b10bd68223d57cce39c08947efae3f91ac40b552e07c7c869c3350a0fc4c23"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ea4cb5022462d9f15e7b72a52c267385aa97ac224c8503ac63ba90c0067ad0d9"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-win32.whl", hash = "sha256:c3ffbe6c680c626403b7e9697d92465501078731b9e8863b1410826e66b950e1"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-win_amd64.whl", hash = "sha256:061d089901319463a1160e4596191e355d2a9b4cce4852dac5adc301b085ac2f"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314-win_arm64.whl", hash = "sha256:f5bc7fb93f1c17efefeb062bc4d2742ad1b71d56c36e6e2898a371ac6a56839a"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9bb993b1d0890a2da90589684c3d09de75df82c06aa8f7eda246c7cd0ea3e820"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df7e71ed051e4003e73484ef6547aacfb3a2322aac0c230d530c97e48e77b4d7"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c76d4cb9c76a6906563133449a76d9eae6c8529ca09484b8615ca604442b442e"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59a56be2e4e972061f9d5c1d5b01763b8b2207b48b956fbb6879c7443f5817f7"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc1a61d7f42e71746b5a2e10e90a6b174b4add441b7b6cf14278f1e029b2b800"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:740e96dfb99a615c8056070d3747efe8bce4c145e5953ccc9e4b004c139de0d2"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:92a0bb0595331c8e40bc615a67cc7bca9b761487409d1bf46c598b6ba5c0754d"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-win32.whl", hash = "sha256:11185c72b5372369166cb721537943f8e1e6e42a399e9bac19e02f3ee20592a4"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5173207b8fe1ddfc1d451c420a2a9cbbd0734b4a19896b8080e3744497e1c20c"},
|
||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-win_arm64.whl", hash = "sha256:def2f117a6ec0a25202ee36a148bbcfad3d43e2f2bb2e698e6d4448c6db5cb19"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:a0b80f91d9ed92be7a134278ea2e83a693c07d9986824ca51d859af9e57e18f5"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3033ddeeeb34c6c536622c07e932a20f8c9f58d6dd76e56595a76b55a25011ff"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4d30723171d511ec16ac057872a38b9b395420c0aaf81b2f94dd7bf1345c5fe"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cefa5badc13df1c9082bacdfa435c8eb6dcb9b32717f51d09d1e160a599646f"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2549e503185897de1fc5ca7730d5daed0d27d0b26df4a059dcdd33ebe81b7b4b"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:5a6871ee159a943d874012de835d56d0544ee871a99dbae05bc053da315b2c23"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:51e290080415325b66a94d20f6360e310606acb832f2bd677d4fcb95728e7ce1"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-win32.whl", hash = "sha256:c228084025a28aa038aef9b821d842508650fe12fe51f06e9cdaa4393be7812c"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-win_amd64.whl", hash = "sha256:bc09dd22c3fc491a2df7d16ca4167de35573f40c2e82dd4d6ddae589a73e7032"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315-win_arm64.whl", hash = "sha256:375ef1bf78bc269a220963163d2580d5377898ed23a00bdd98a02d755421573d"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:7af1c92255cf8d50e87a808ce41096907332acdd09f285426a52f7c300e152db"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85891e0828e76a77fe912995dbc3ee949099488ba0a2ab323535d2e767446b20"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a72e062d2d38826dd89231df692fb40f12d7d1b9d118be23bcc1cf93c6e31782"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b79ee943e2e12246f873345762b83dbadd1fcfc3e832eab83272d672704dbdea"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:e9418d87b55c469c2f1328246d4533238cdc43134c695d6b9a6b7a87da49d463"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:898d86c22640fe52ffe02b4abc043a3c6754b4875837599e739be440966dffa1"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b3ad20cd035392f2b91a6466e24643990c1378beda80c9bda87a3a41e841e838"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-win32.whl", hash = "sha256:db34eefaf992c84460ab26ecf2fc2bdcc68965d6becfae8182cae0efeaa3e8f8"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-win_amd64.whl", hash = "sha256:173054351d863ddada188284e309e87a9f030132c8d534e05458971ff061aa1e"},
|
||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-win_arm64.whl", hash = "sha256:22c8d0873168a54f614e381559ea83d547b2cc5488b95b049bdc92667586bd5f"},
|
||||
{file = "sqlalchemy-2.1.5-py3-none-any.whl", hash = "sha256:2f77b77b716f005b2dc3cc059d56df6a919bfb5b439e4149890a9df6c36be9e3"},
|
||||
{file = "sqlalchemy-2.1.5.tar.gz", hash = "sha256:0df76cf1ca680ad745bd378a6299a058611a8a784eeaeb4ed78ad4a0b7ce2003"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -665,7 +765,7 @@ name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Backported and Experimental Type Hints for Python 3.9+"
|
||||
groups = ["default"]
|
||||
groups = ["default", "notify"]
|
||||
files = [
|
||||
{file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"},
|
||||
{file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"},
|
||||
@@ -673,7 +773,7 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3-future"
|
||||
version = "2.25.902"
|
||||
version = "2.25.903"
|
||||
requires_python = ">=3.7"
|
||||
summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces"
|
||||
groups = ["default"]
|
||||
@@ -683,8 +783,8 @@ dependencies = [
|
||||
"qh3<3.0.0,>=1.5.4; (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\"",
|
||||
]
|
||||
files = [
|
||||
{file = "urllib3_future-2.25.902-py3-none-any.whl", hash = "sha256:88e6f3c19f553931e78f84f011a34220f827c971e6cf06b3a075eaa788c2bf7d"},
|
||||
{file = "urllib3_future-2.25.902.tar.gz", hash = "sha256:5c443668b9f87bce38bad1fdec7d7950c47e9c5b7328b1fd029a429741f3be70"},
|
||||
{file = "urllib3_future-2.25.903-py3-none-any.whl", hash = "sha256:39352d455023402d86c4c1f567eba4f8b1bbf65571f8df96c2f5697c8058825f"},
|
||||
{file = "urllib3_future-2.25.903.tar.gz", hash = "sha256:ef0011def1c6724f418d7970200ab8ccaf7d034ece152717b368afe512fb6d53"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -698,3 +798,105 @@ files = [
|
||||
{file = "wassima-2.1.4-py3-none-any.whl", hash = "sha256:26022272d4618aada0e26df2e79dbc0e034be8be9d6d5396512690ffc6a94e7b"},
|
||||
{file = "wassima-2.1.4.tar.gz", hash = "sha256:21bb77253bb8032c05393172c4d2df395f423b7e704538f3b2407f50c032034d"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-runtime"
|
||||
version = "3.2.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"typing-extensions>=4.12.2",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_runtime-3.2.1-cp314-cp314-win32.whl", hash = "sha256:9b6298375468ac2f6815d0c008a059fc16508c8f587e824c7936ed9216480dad"},
|
||||
{file = "winrt_runtime-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:e36e587ab5fd681ee472cd9a5995743f75107a1a84d749c64f7e490bc86bc814"},
|
||||
{file = "winrt_runtime-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:35d6241a2ebd5598e4788e69768b8890ee1eee401a819865767a1fbdd3e9a650"},
|
||||
{file = "winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-applicationmodel-core"
|
||||
version = "3.2.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"winrt-runtime~=3.2.1.0",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_windows_applicationmodel_core-3.2.1-cp314-cp314-win32.whl", hash = "sha256:2a940b018989ba7159ace4c93eab5f5bce303ac5325ab29eeb0effe1d4a486a7"},
|
||||
{file = "winrt_windows_applicationmodel_core-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:52f2e1cd5fa0a5b0592ddc40b423255491a40a2d6e4cf6ae766dd3b4810557ec"},
|
||||
{file = "winrt_windows_applicationmodel_core-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:a0c18662c835fbb5044a5c3ca021d8f2f3f290c6238543ed74112a18e3d7d878"},
|
||||
{file = "winrt_windows_applicationmodel_core-3.2.1.tar.gz", hash = "sha256:7cd21a4a7e25b099703a607f17ce804b5166cdf6444437440590fafea990fb46"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-data-xml-dom"
|
||||
version = "3.2.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"winrt-runtime~=3.2.1.0",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win32.whl", hash = "sha256:77258030a725a32359fd2a93855cafa0e3f3f38e67afd5dfe74a8a215cbb31c4"},
|
||||
{file = "winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:1cf1b6f31fff4e4c0ae30f1643b169da72b3b053a2996010f2c3a1e26b5d4970"},
|
||||
{file = "winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:6de83f853d889444e4897413c2fa8183f86aa602edd34abf36a4fdc69db1650e"},
|
||||
{file = "winrt_windows_data_xml_dom-3.2.1.tar.gz", hash = "sha256:db81f5956bf63ce8c9d8bfb6d0794ab2849f26e62a6256b833af8de3d6451399"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-foundation"
|
||||
version = "3.2.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"winrt-runtime~=3.2.1.0",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win32.whl", hash = "sha256:35e973ab3c77c2a943e139302256c040e017fd6ff1a75911c102964603bba1da"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22a7ebcec0d262e60119cff728f32962a02df60471ded8b2735a655eccc0ef5"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be7fbae829b98a6a946db4fbaf356b11db1fbcbb5d4f37e7a73ac6b25de8b87"},
|
||||
{file = "winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-foundation-collections"
|
||||
version = "3.2.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"winrt-runtime~=3.2.1.0",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win32.whl", hash = "sha256:33188ed2d63e844c8adfbb82d1d3d461d64aaf78d225ce9c5930421b413c45ab"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d4cfece7e9c0ead2941e55a1da82f20d2b9c8003bb7a8853bb7f999b539f80a4"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3884146fea13727510458f6a14040b7632d5d90127028b9bfd503c6c655d0c01"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-ui-notifications"
|
||||
version = "3.2.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"winrt-runtime~=3.2.1.0",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_windows_ui_notifications-3.2.1-cp314-cp314-win32.whl", hash = "sha256:00a7579ca0870134c27def1418c259fb18efe5b88aa399de85b4712f1add1b55"},
|
||||
{file = "winrt_windows_ui_notifications-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:943599c727abf710ae94644b1d521e11857bd568e080e894a8be11aa717e383a"},
|
||||
{file = "winrt_windows_ui_notifications-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:2a496c7e796b537966df7b26753b20bc40c5c1fbe96b3b3051a1debc00b80f07"},
|
||||
{file = "winrt_windows_ui_notifications-3.2.1.tar.gz", hash = "sha256:eb29bf1c3306f3ceabd71d092b5505d5fe73fbc0660347dcc63ebc4204d34d21"},
|
||||
]
|
||||
|
||||
+7
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "plyngent"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
description = "LLM chat and agent toolkit"
|
||||
authors = [
|
||||
{ name = "HivertMoZara", email = "worldmozara@163.com" },
|
||||
@@ -16,12 +16,17 @@ dependencies = [
|
||||
"click>=8.4.2",
|
||||
"awaitlet>=0.0.1",
|
||||
"pywinpty>=2.0.0; sys_platform == \"win32\"",
|
||||
"rich>=13"
|
||||
"rich>=13",
|
||||
"send2trash>=2.1.0"
|
||||
]
|
||||
requires-python = ">=3.14,<4"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Desktop notifications while a prompt waits on the human (see `[notify]`).
|
||||
notify = ["desktop-notifier>=6.2.0"]
|
||||
|
||||
[project.scripts]
|
||||
plyngent = "plyngent.cli:main"
|
||||
|
||||
|
||||
@@ -30,10 +30,12 @@ from .notices import skill_tools_notice as skill_tools_notice
|
||||
from .notices import truncated_turn_notice as truncated_turn_notice
|
||||
from .tools import BatchConfirmDecision as BatchConfirmDecision
|
||||
from .tools import DangerClassifier as DangerClassifier
|
||||
from .tools import DangerReason as DangerReason
|
||||
from .tools import ToolBatchConfirmHook as ToolBatchConfirmHook
|
||||
from .tools import ToolConfirmHook as ToolConfirmHook
|
||||
from .tools import ToolConfirmRequest as ToolConfirmRequest
|
||||
from .tools import ToolDefinition as ToolDefinition
|
||||
from .tools import ToolOutput as ToolOutput
|
||||
from .tools import ToolRegistry as ToolRegistry
|
||||
from .tools import ToolTag as ToolTag
|
||||
from .tools import schema_from_callable as schema_from_callable
|
||||
|
||||
@@ -7,7 +7,10 @@ from msgspec import UNSET
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantChatMessage,
|
||||
ChatImagePart,
|
||||
ChatTextPart,
|
||||
ToolChatMessage,
|
||||
UserChatMessage,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -24,6 +27,11 @@ DEFAULT_OLD_TOOL_RESULT_CHARS = 800
|
||||
# Soft-compact: leave this many most-recent tool results at full size.
|
||||
DEFAULT_RECENT_TOOL_RESULTS = 12
|
||||
|
||||
# What one image costs in chars: a provider bills it by its resized size, at most
|
||||
# 1024 tokens, and this estimator works in chars at ~4 chars/token. The shrinkers
|
||||
# below never drop an image, so they must at least count it.
|
||||
IMAGE_CHARS = 4_096
|
||||
|
||||
# Backward-compat alias (older code/docs may still import this name).
|
||||
DEFAULT_CONTEXT_MAX_CHARS = DEFAULT_CONTEXT_MAX_TOKENS * 4
|
||||
|
||||
@@ -50,12 +58,21 @@ def truncate_tool_result(
|
||||
return truncate_generic(text, max_chars)
|
||||
|
||||
|
||||
def _content_chars(content: str | list[ChatTextPart | ChatImagePart]) -> int:
|
||||
if isinstance(content, str):
|
||||
return len(content)
|
||||
return sum(len(part.text) if isinstance(part, ChatTextPart) else IMAGE_CHARS for part in content)
|
||||
|
||||
|
||||
def estimate_message_chars(message: AnyChatMessage) -> int:
|
||||
"""Rough character estimate for soft context budgeting (not tokenizer-accurate)."""
|
||||
total = 0
|
||||
content = getattr(message, "content", None)
|
||||
if isinstance(content, str):
|
||||
total += len(content)
|
||||
if isinstance(message, UserChatMessage):
|
||||
total += _content_chars(message.content)
|
||||
else:
|
||||
content = getattr(message, "content", None)
|
||||
if isinstance(content, str):
|
||||
total += len(content)
|
||||
if isinstance(message, AssistantChatMessage):
|
||||
tool_calls = message.tool_calls
|
||||
if tool_calls is not UNSET and tool_calls:
|
||||
@@ -71,6 +88,9 @@ def estimate_message_chars(message: AnyChatMessage) -> int:
|
||||
total += len(getattr(custom, "input", "") or "")
|
||||
if isinstance(message, ToolChatMessage):
|
||||
total += len(message.tool_call_id)
|
||||
images = message.images
|
||||
if images is not UNSET and images:
|
||||
total += IMAGE_CHARS * len(images)
|
||||
return total
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from plyngent.lmproto.openai_compatible.model import (
|
||||
SystemChatMessage,
|
||||
ToolChatMessage,
|
||||
UserChatMessage,
|
||||
content_to_text,
|
||||
)
|
||||
|
||||
from .budget import (
|
||||
@@ -27,6 +28,7 @@ from .directive_checkpoint import (
|
||||
parse_checkpoint_bands,
|
||||
)
|
||||
from .events import UsageEvent
|
||||
from .images import user_content
|
||||
from .loop import DEFAULT_MAX_ROUNDS, DEFAULT_MAX_TRUNCATION_CONTINUES, run_chat_loop
|
||||
from .notices import TRUNCATED_NOTICE_KIND, notice_of_kind
|
||||
from .todo_nag import (
|
||||
@@ -40,7 +42,7 @@ from .usage import TokenUsage
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import AnyAssistantToolCall, AnyChatMessage
|
||||
from plyngent.lmproto.openai_compatible.model import AnyAssistantToolCall, AnyChatMessage, ChatImagePart
|
||||
from plyngent.memory import MemoryStore
|
||||
|
||||
from .events import AgentEvent
|
||||
@@ -162,12 +164,12 @@ def incomplete_turn_user_text(messages: Sequence[AnyChatMessage]) -> str | None:
|
||||
return None
|
||||
last = body[-1]
|
||||
if isinstance(last, UserChatMessage):
|
||||
return last.content
|
||||
return content_to_text(last.content)
|
||||
if isinstance(last, ToolChatMessage) or (truncated and isinstance(last, AssistantChatMessage)):
|
||||
# Walk back to the user that opened this turn.
|
||||
for msg in reversed(body):
|
||||
if isinstance(msg, UserChatMessage):
|
||||
return msg.content
|
||||
return content_to_text(msg.content)
|
||||
return None
|
||||
return None
|
||||
|
||||
@@ -558,7 +560,9 @@ class ChatAgent:
|
||||
raise RuntimeError(msg)
|
||||
for i in range(len(self.messages) - 1, -1, -1):
|
||||
msg = self.messages[i]
|
||||
if isinstance(msg, UserChatMessage) and msg.content == text:
|
||||
# ``incomplete_turn_user_text`` returns the text view, so compare that
|
||||
# (a turn carrying images is a part array, not a string).
|
||||
if isinstance(msg, UserChatMessage) and content_to_text(msg.content) == text:
|
||||
return i
|
||||
msg = "nothing to retry"
|
||||
raise RuntimeError(msg)
|
||||
@@ -661,11 +665,20 @@ class ChatAgent:
|
||||
await self._persist_range(self._persist_from, len(self.messages))
|
||||
await self._persist_context_usage()
|
||||
|
||||
async def run(self, user_text: str) -> AsyncIterator[AgentEvent]:
|
||||
"""Append a user message (persist immediately), run the tool loop, yield events."""
|
||||
async def run(
|
||||
self,
|
||||
user_text: str,
|
||||
*,
|
||||
images: Sequence[ChatImagePart] | None = None,
|
||||
) -> AsyncIterator[AgentEvent]:
|
||||
"""Append a user message (persist immediately), run the tool loop, yield events.
|
||||
|
||||
*images* ride along with the text as content parts, which is the shape
|
||||
the model sees an attachment in (see ``agent.images``).
|
||||
"""
|
||||
_ = reset_lineno_tracker()
|
||||
self._ensure_system_prompt()
|
||||
user_msg = UserChatMessage(content=user_text)
|
||||
user_msg = UserChatMessage(content=user_content(user_text, images))
|
||||
self.messages.append(user_msg)
|
||||
await self._persist(user_msg)
|
||||
self._persist_from = len(self.messages)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Image parts for the model: data URLs, format sniffing, and the size policy.
|
||||
|
||||
A model-visible image is a :class:`ChatImagePart` holding a URL — an ``http(s)``
|
||||
link or an inline ``data:`` URL. Providers sniff the real format from the bytes
|
||||
(never from a file name), so this module sniffs too and builds the data URL from
|
||||
what it found.
|
||||
|
||||
Inline bytes are capped at :data:`MAX_INLINE_IMAGE_BYTES`, well under the 32 MiB
|
||||
a provider accepts: every stored message is re-sent on each round and lives in
|
||||
the session's JSON message column, so a huge screenshot would be paid for over
|
||||
and over. A larger image has to be resized first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
ChatImagePart,
|
||||
ChatImageUrl,
|
||||
ChatTextPart,
|
||||
ImageDetail,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import ChatContentPart
|
||||
|
||||
# Inline payload ceiling for one image (the API allows 32 MiB; see the module docstring).
|
||||
MAX_INLINE_IMAGE_BYTES = 8 * 1024 * 1024
|
||||
|
||||
# Formatted images the API accepts, by their leading bytes.
|
||||
_IMAGE_SIGNATURES: tuple[tuple[bytes, str], ...] = (
|
||||
(b"\x89PNG\r\n\x1a\n", "image/png"),
|
||||
(b"\xff\xd8\xff", "image/jpeg"),
|
||||
(b"GIF87a", "image/gif"),
|
||||
(b"GIF89a", "image/gif"),
|
||||
)
|
||||
# RIFF....WEBP: four bytes of container, four of size, then the form type.
|
||||
_WEBP_HEADER_LEN = 12
|
||||
|
||||
|
||||
def sniff_image_media_type(data: bytes) -> str | None:
|
||||
"""Media type of an image *data* by its content, or ``None`` if unsupported."""
|
||||
for signature, media_type in _IMAGE_SIGNATURES:
|
||||
if data.startswith(signature):
|
||||
return media_type
|
||||
# WebP: RIFF....WEBP
|
||||
if len(data) >= _WEBP_HEADER_LEN and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||
return "image/webp"
|
||||
return None
|
||||
|
||||
|
||||
def to_data_url(media_type: str, data: bytes) -> str:
|
||||
"""Encode *data* as an inline ``data:`` URL of *media_type*."""
|
||||
return f"data:{media_type};base64,{base64.b64encode(data).decode('ascii')}"
|
||||
|
||||
|
||||
def split_data_url(url: str) -> tuple[str, str] | None:
|
||||
"""``(media_type, base64_payload)`` of a ``data:`` URL, or ``None`` otherwise."""
|
||||
if not url.startswith("data:"):
|
||||
return None
|
||||
header, _, payload = url.partition(",")
|
||||
media_type = header[len("data:") :].split(";")[0].strip()
|
||||
if not media_type or not payload:
|
||||
return None
|
||||
return media_type, payload
|
||||
|
||||
|
||||
def image_part_from_bytes(data: bytes, *, detail: ImageDetail = "auto") -> ChatImagePart | str:
|
||||
"""An image part for *data*; a string is the model-facing error instead."""
|
||||
media_type = sniff_image_media_type(data)
|
||||
if media_type is None:
|
||||
return "error: not a JPEG, PNG, GIF, or WebP image"
|
||||
if len(data) > MAX_INLINE_IMAGE_BYTES:
|
||||
return (
|
||||
f"error: image is {len(data)} bytes, over the {MAX_INLINE_IMAGE_BYTES}-byte inline limit; "
|
||||
"resize it and try again"
|
||||
)
|
||||
return ChatImagePart(image_url=ChatImageUrl(url=to_data_url(media_type, data), detail=detail))
|
||||
|
||||
|
||||
def image_part_from_url(url: str, *, detail: ImageDetail = "auto") -> ChatImagePart:
|
||||
"""An image part for a public ``http(s)`` URL or an inline ``data:`` URL."""
|
||||
return ChatImagePart(image_url=ChatImageUrl(url=url, detail=detail))
|
||||
|
||||
|
||||
def user_content(
|
||||
text: str,
|
||||
images: Sequence[ChatImagePart] | None = None,
|
||||
) -> str | list[ChatContentPart]:
|
||||
"""A user turn's content: the text alone, or a part array when images ride along."""
|
||||
if not images:
|
||||
return text
|
||||
parts: list[ChatContentPart] = []
|
||||
if text:
|
||||
parts.append(ChatTextPart(text=text))
|
||||
parts.extend(images)
|
||||
return parts
|
||||
@@ -93,7 +93,7 @@ async def _run_one_tool(
|
||||
) -> tuple[ToolChatMessage, ErrorEvent | None]:
|
||||
if isinstance(call, AssistantFunctionToolCall):
|
||||
try:
|
||||
result_text = await tools.execute(call.function.name, call.function.arguments, decision=decision)
|
||||
output = await tools.execute_output(call.function.name, call.function.arguments, decision=decision)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@@ -101,8 +101,16 @@ async def _run_one_tool(
|
||||
err = ErrorEvent(message=result_text, retryable=True, source="tool")
|
||||
truncated = truncate_tool_result(result_text, max_result_chars)
|
||||
return ToolChatMessage(content=truncated, tool_call_id=call.id), err
|
||||
truncated = truncate_tool_result(result_text, max_result_chars)
|
||||
return ToolChatMessage(content=truncated, tool_call_id=call.id), None
|
||||
# Only the text is capped; an image the tool returned rides along whole.
|
||||
truncated = truncate_tool_result(output.text, max_result_chars)
|
||||
return (
|
||||
ToolChatMessage(
|
||||
content=truncated,
|
||||
tool_call_id=call.id,
|
||||
images=output.images or UNSET,
|
||||
),
|
||||
None,
|
||||
)
|
||||
msg = ToolChatMessage(
|
||||
content="error: custom tool calls are not supported",
|
||||
tool_call_id=call.id,
|
||||
|
||||
@@ -13,8 +13,10 @@ from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from msgspec import UNSET
|
||||
|
||||
from plyngent.agent.images import split_data_url
|
||||
from plyngent.lmproto.anthropic.model import (
|
||||
AnthropicAssistantMessage,
|
||||
AnthropicImageContent,
|
||||
AnthropicMessageResponse,
|
||||
AnthropicMessagesParam,
|
||||
AnthropicOutputConfig,
|
||||
@@ -39,8 +41,11 @@ from plyngent.lmproto.openai_compatible.model import (
|
||||
ChatCompletionChoice,
|
||||
ChatCompletionResponse,
|
||||
ChatCompletionsParam,
|
||||
ChatImagePart,
|
||||
ChatTextPart,
|
||||
DeveloperChatMessage,
|
||||
SystemChatMessage,
|
||||
ToolChatMessage,
|
||||
ToolFunctionItem,
|
||||
UserChatMessage,
|
||||
)
|
||||
@@ -135,6 +140,52 @@ def _tool_choice_from_chat(param: ChatCompletionsParam) -> AnthropicToolChoice |
|
||||
return UNSET
|
||||
|
||||
|
||||
def _image_to_anthropic(part: ChatImagePart) -> AnthropicImageContent:
|
||||
"""One chat image part as an Anthropic ``image`` block.
|
||||
|
||||
An inline ``data:`` URL becomes a ``base64`` source (the provider then reads
|
||||
the media type we sniffed); anything else is a ``url`` source.
|
||||
"""
|
||||
inline = split_data_url(part.image_url.url)
|
||||
if inline is None:
|
||||
return AnthropicImageContent(source={"type": "url", "url": part.image_url.url})
|
||||
media_type, payload = inline
|
||||
return AnthropicImageContent(source={"type": "base64", "media_type": media_type, "data": payload})
|
||||
|
||||
|
||||
type _AnthropicUserBlock = AnthropicTextContent | AnthropicImageContent | AnthropicToolResultContent
|
||||
|
||||
|
||||
def _user_blocks_to_anthropic(
|
||||
content: str | list[ChatTextPart | ChatImagePart],
|
||||
) -> str | list[_AnthropicUserBlock]:
|
||||
"""A chat user turn's content as Anthropic blocks (plain text stays a string)."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
return [
|
||||
AnthropicTextContent(text=part.text) if isinstance(part, ChatTextPart) else _image_to_anthropic(part)
|
||||
for part in content
|
||||
]
|
||||
|
||||
|
||||
def _tool_result_content(
|
||||
message: ToolChatMessage,
|
||||
) -> str | list[AnthropicTextContent | AnthropicImageContent]:
|
||||
"""A tool result's content: its text, plus any images the tool returned.
|
||||
|
||||
Anthropic takes image blocks inside ``tool_result.content`` (a live probe
|
||||
confirms the DeepSeek surface reads them), so the images ride in the result
|
||||
itself instead of a separate user turn.
|
||||
"""
|
||||
if message.images is UNSET or not message.images:
|
||||
return message.content
|
||||
blocks: list[AnthropicTextContent | AnthropicImageContent] = []
|
||||
if message.content:
|
||||
blocks.append(AnthropicTextContent(text=message.content))
|
||||
blocks.extend(_image_to_anthropic(part) for part in message.images)
|
||||
return blocks
|
||||
|
||||
|
||||
def _assistant_to_anthropic(message: AssistantChatMessage) -> AnthropicAssistantMessage | None:
|
||||
"""The assistant turn for the wire: opaque blocks, then text, then calls.
|
||||
|
||||
@@ -211,7 +262,7 @@ def chat_messages_to_anthropic( # noqa: C901 — multi-role conversion
|
||||
|
||||
if isinstance(message, UserChatMessage):
|
||||
flush_tool_results()
|
||||
out.append(AnthropicUserMessage(content=message.content))
|
||||
out.append(AnthropicUserMessage(content=_user_blocks_to_anthropic(message.content)))
|
||||
continue
|
||||
|
||||
if isinstance(message, AssistantChatMessage):
|
||||
@@ -225,7 +276,7 @@ def chat_messages_to_anthropic( # noqa: C901 — multi-role conversion
|
||||
pending_tool_results.append(
|
||||
AnthropicToolResultContent(
|
||||
tool_use_id=message.tool_call_id,
|
||||
content=message.content,
|
||||
content=_tool_result_content(message),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -29,8 +29,11 @@ from plyngent.lmproto.openai_compatible.model import (
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionResponse,
|
||||
ChatCompletionsParam,
|
||||
ChatImagePart,
|
||||
ChatTextPart,
|
||||
DeveloperChatMessage,
|
||||
SystemChatMessage,
|
||||
ToolChatMessage,
|
||||
ToolFunctionItem,
|
||||
UserChatMessage,
|
||||
)
|
||||
@@ -175,6 +178,35 @@ def _group_tool_outputs(
|
||||
return out
|
||||
|
||||
|
||||
def _image_to_input_part(part: ChatImagePart) -> dict[str, Any]:
|
||||
"""One chat image part as a Responses ``input_image`` item."""
|
||||
item: dict[str, Any] = {"type": "input_image", "image_url": part.image_url.url}
|
||||
if part.image_url.detail is not UNSET:
|
||||
item["detail"] = part.image_url.detail
|
||||
return item
|
||||
|
||||
|
||||
def _user_content_to_input(content: str | list[ChatTextPart | ChatImagePart]) -> str | list[dict[str, Any]]:
|
||||
"""A chat user turn's content as ``input`` content (plain text stays a string)."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
return [
|
||||
{"type": "input_text", "text": part.text} if isinstance(part, ChatTextPart) else _image_to_input_part(part)
|
||||
for part in content
|
||||
]
|
||||
|
||||
|
||||
def _tool_output(message: ToolChatMessage) -> str | list[dict[str, Any]]:
|
||||
"""A tool result's ``output``: its text, plus any images the tool returned."""
|
||||
if message.images is UNSET or not message.images:
|
||||
return message.content
|
||||
parts: list[dict[str, Any]] = []
|
||||
if message.content:
|
||||
parts.append({"type": "input_text", "text": message.content})
|
||||
parts.extend(_image_to_input_part(part) for part in message.images)
|
||||
return parts
|
||||
|
||||
|
||||
def chat_messages_to_responses_input(
|
||||
messages: Sequence[AnyChatMessage],
|
||||
) -> tuple[str | None, list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput]]:
|
||||
@@ -191,7 +223,7 @@ def chat_messages_to_responses_input(
|
||||
if message.content.strip():
|
||||
items.append(ResponseEasyInputMessage(role="developer", content=message.content))
|
||||
elif isinstance(message, UserChatMessage):
|
||||
items.append(ResponseEasyInputMessage(role="user", content=message.content))
|
||||
items.append(ResponseEasyInputMessage(role="user", content=_user_content_to_input(message.content)))
|
||||
elif isinstance(message, AssistantChatMessage):
|
||||
items.extend(_assistant_to_input_items(message))
|
||||
else:
|
||||
@@ -199,7 +231,7 @@ def chat_messages_to_responses_input(
|
||||
items.append(
|
||||
ResponseFunctionToolCallOutput(
|
||||
call_id=message.tool_call_id,
|
||||
output=message.content,
|
||||
output=_tool_output(message),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+87
-25
@@ -11,12 +11,27 @@ from pathlib import Path
|
||||
from typing import Any, cast, get_args, get_origin, get_type_hints, overload
|
||||
|
||||
import msgspec
|
||||
from msgspec import Struct
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import ToolFunction, ToolFunctionItem
|
||||
from plyngent.lmproto.openai_compatible.model import ChatImagePart, ToolFunction, ToolFunctionItem
|
||||
from plyngent.typedef import JSONSchema # noqa: TC001
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DangerReason:
|
||||
"""Why a call needs a soft confirm, and whether YOLO mode may drop that prompt.
|
||||
|
||||
``yolo_skip`` is false for a call that reaches rights the session does not
|
||||
have (``sudo`` / ``doas`` / ``pkexec`` and friends): the human answers it
|
||||
however YOLO mode is set.
|
||||
"""
|
||||
|
||||
text: str
|
||||
yolo_skip: bool = True
|
||||
|
||||
|
||||
type ToolHandler = Callable[..., Any | Awaitable[Any]]
|
||||
type DangerClassifier = Callable[[str, Mapping[str, object]], str | None]
|
||||
type DangerClassifier = Callable[[str, Mapping[str, object]], str | DangerReason | None]
|
||||
# Returns True to allow, False to deny, or a non-empty str as denial reason for the model.
|
||||
type ToolConfirmResult = bool | str
|
||||
type ToolConfirmHook = Callable[
|
||||
@@ -25,6 +40,24 @@ type ToolConfirmHook = Callable[
|
||||
]
|
||||
|
||||
|
||||
class ToolOutput(Struct):
|
||||
"""A tool result: the model-facing text, plus images the model should see.
|
||||
|
||||
Most tools return their text directly; one that produced an image (a
|
||||
screenshot, a chart, a downloaded picture) returns this so the image rides
|
||||
along with the text that explains it. ``execute`` hands the pair to the
|
||||
loop, which keeps the text as the tool message's content and the images
|
||||
beside it.
|
||||
"""
|
||||
|
||||
text: str
|
||||
images: list[ChatImagePart] = msgspec.field(default_factory=list)
|
||||
|
||||
@property
|
||||
def has_images(self) -> bool:
|
||||
return bool(self.images)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ToolConfirmRequest:
|
||||
"""One call of a batch that needs a soft confirm before it may run."""
|
||||
@@ -294,6 +327,13 @@ def _denied_error(name: str, reason: str, comment: str | None) -> str:
|
||||
return f"error: tool {name!r} denied by user confirm ({reason})"
|
||||
|
||||
|
||||
def _as_danger_reason(raw: str | DangerReason | None) -> DangerReason | None:
|
||||
"""Normalize a classifier answer; a bare string is a reason YOLO mode may drop."""
|
||||
if raw is None:
|
||||
return None
|
||||
return raw if isinstance(raw, DangerReason) else DangerReason(text=raw)
|
||||
|
||||
|
||||
def _write_path_key(value: str) -> str:
|
||||
"""Comparable key for one path argument of a writing tool.
|
||||
|
||||
@@ -444,7 +484,7 @@ class ToolRegistry:
|
||||
return f"error: tool {definition.name!r} requires session state (SESSION_STATE) but none is bound"
|
||||
return None
|
||||
|
||||
async def _invoke(self, definition: ToolDefinition, args: dict[str, object]) -> str:
|
||||
async def _invoke(self, definition: ToolDefinition, args: dict[str, object]) -> str | ToolOutput:
|
||||
try:
|
||||
result = definition.handler(**args)
|
||||
if inspect.isawaitable(result):
|
||||
@@ -455,7 +495,7 @@ class ToolRegistry:
|
||||
return f"error: invalid tool arguments: {exc}"
|
||||
except Exception as exc: # noqa: BLE001 — surface tool failures to the model
|
||||
return f"error: tool {definition.name!r} failed: {exc}"
|
||||
if isinstance(result, str):
|
||||
if isinstance(result, str | ToolOutput):
|
||||
return result
|
||||
return msgspec.json.encode(result).decode()
|
||||
|
||||
@@ -540,7 +580,9 @@ class ToolRegistry:
|
||||
|
||||
Mirrors the front half of :meth:`execute`: a call whose arguments fail to
|
||||
decode or miss a required argument is answered with an error there and
|
||||
must not be put in front of the user now.
|
||||
must not be put in front of the user now, and a call whose reason YOLO
|
||||
mode covers runs unprompted, so it must not take a slot in the round's
|
||||
aggregated prompt either.
|
||||
"""
|
||||
definition = self._tools.get(name)
|
||||
if self._danger is None or definition is None:
|
||||
@@ -548,8 +590,10 @@ class ToolRegistry:
|
||||
args = _decode_tool_args(arguments_json)
|
||||
if isinstance(args, str) or _missing_required_error(definition, args) is not None:
|
||||
return None
|
||||
reason = self._danger(name, args)
|
||||
return None if reason is None else ToolConfirmRequest(name=name, args=args, reason=reason)
|
||||
reason = _as_danger_reason(self._danger(name, args))
|
||||
if reason is None or (self._yolo and (definition.tags & ToolTag.YOLO) and reason.yolo_skip):
|
||||
return None
|
||||
return ToolConfirmRequest(name=name, args=args, reason=reason.text)
|
||||
|
||||
def _batch_requests(
|
||||
self,
|
||||
@@ -614,29 +658,32 @@ class ToolRegistry:
|
||||
tags: ToolTag,
|
||||
decision: BatchConfirmDecision | None = None,
|
||||
) -> str | None:
|
||||
"""Soft-confirm gate driven by danger reason + tags + grants.
|
||||
"""Soft-confirm gate driven by danger reason + tags + grants + YOLO.
|
||||
|
||||
Pipeline (soft gate only; hard denylists stay in tool handlers)::
|
||||
|
||||
no soft reason → run
|
||||
YOLO mode, (tags & YOLO) and a reason it may drop → allow
|
||||
batch decision for this call → apply it
|
||||
(tags & TRUSTABLE) and grant exists → allow
|
||||
else → on_confirm (decorator YOLO tag does not override
|
||||
dangerous invocations — internal danger classification
|
||||
always takes precedence)
|
||||
else → on_confirm (a tool without the YOLO tag, or a reason YOLO
|
||||
may not drop — a privilege escalation — still prompts
|
||||
under YOLO mode)
|
||||
"""
|
||||
if self._danger is None:
|
||||
return None
|
||||
reason = self._danger(name, args)
|
||||
reason = _as_danger_reason(self._danger(name, args))
|
||||
if reason is None:
|
||||
return None
|
||||
if self._yolo and (tags & ToolTag.YOLO) and reason.yolo_skip:
|
||||
return None
|
||||
|
||||
async with self._confirm_lock:
|
||||
if decision is not None:
|
||||
return await self._apply_batch_decision(name, reason, decision, tags=tags)
|
||||
return await self._apply_batch_decision(name, reason.text, decision, tags=tags)
|
||||
if self._grant_allows(name, tags=tags):
|
||||
return None
|
||||
return await self._prompt_soft_confirm(name, args, reason, tags=tags)
|
||||
return await self._prompt_soft_confirm(name, args, reason.text, tags=tags)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
@@ -645,28 +692,42 @@ class ToolRegistry:
|
||||
*,
|
||||
decision: BatchConfirmDecision | None = None,
|
||||
) -> str:
|
||||
"""Run a tool by name; returns a string result (errors become error text).
|
||||
"""Run a tool by name; returns its text (errors become error text).
|
||||
|
||||
*decision* is the host's answer for this call from
|
||||
:meth:`prepare_batch_confirm`; ``None`` means the call was not part of an
|
||||
aggregated prompt and confirms on its own.
|
||||
An image the tool returned is dropped here — this is the text-only entry
|
||||
point. :meth:`execute_output` returns it; the agent loop uses that one.
|
||||
"""
|
||||
return (await self.execute_output(name, arguments_json, decision=decision)).text
|
||||
|
||||
async def execute_output(
|
||||
self,
|
||||
name: str,
|
||||
arguments_json: str,
|
||||
*,
|
||||
decision: BatchConfirmDecision | None = None,
|
||||
) -> ToolOutput:
|
||||
"""Run a tool by name; returns its text plus any images it produced.
|
||||
|
||||
Errors become error text. *decision* is the host's answer for this call
|
||||
from :meth:`prepare_batch_confirm`; ``None`` means the call was not part
|
||||
of an aggregated prompt and confirms on its own.
|
||||
"""
|
||||
definition = self._tools.get(name)
|
||||
if definition is None:
|
||||
return f"error: unknown tool {name!r}"
|
||||
return ToolOutput(text=f"error: unknown tool {name!r}")
|
||||
args = _decode_tool_args(arguments_json)
|
||||
if isinstance(args, str):
|
||||
return args
|
||||
return ToolOutput(text=args)
|
||||
# Argument shape/coverage problems are answered before any confirm prompt:
|
||||
# the human should never be asked about a call that cannot run.
|
||||
missing_error = _missing_required_error(definition, args)
|
||||
if missing_error is not None:
|
||||
return missing_error
|
||||
return ToolOutput(text=missing_error)
|
||||
|
||||
async def _run() -> str:
|
||||
async def _run() -> ToolOutput:
|
||||
missing = self._check_state_tags(definition)
|
||||
if missing is not None:
|
||||
return missing
|
||||
return ToolOutput(text=missing)
|
||||
# Held before the confirm prompt, so a sibling call writing the same
|
||||
# file waits for this one instead of overtaking its prompt.
|
||||
async with AsyncExitStack() as held:
|
||||
@@ -674,8 +735,9 @@ class ToolRegistry:
|
||||
await held.enter_async_context(lock)
|
||||
denied = await self._maybe_confirm(name, args, tags=definition.tags, decision=decision)
|
||||
if denied is not None:
|
||||
return denied
|
||||
return await self._invoke(definition, args)
|
||||
return ToolOutput(text=denied)
|
||||
result = await self._invoke(definition, args)
|
||||
return result if isinstance(result, ToolOutput) else ToolOutput(text=result)
|
||||
|
||||
# Bind host-provided state for tools that read contextvars (todo, workspace).
|
||||
# Tag enforcement remains gated by ``auto_bind_state``; binding is always useful
|
||||
|
||||
+16
-4
@@ -35,6 +35,7 @@ from plyngent.runtime import McpManager, ProviderNotSupportedError, create_clien
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
from plyngent.config.models import NotifyConfig
|
||||
from plyngent.config.store import ConfigStore
|
||||
|
||||
_DEFAULT_DB_FILENAME = "chat.db"
|
||||
@@ -128,13 +129,22 @@ def _read_prompt_text(prompt: str | None, *, stdin_isatty: bool) -> str | None:
|
||||
return text or None
|
||||
|
||||
|
||||
def _setup_hooks(*, interactive: bool, auto_continue: bool) -> None:
|
||||
def _setup_hooks(*, interactive: bool, auto_continue: bool, notify: NotifyConfig) -> None:
|
||||
"""Install limit hooks (workspace policy is set on ReplState).
|
||||
|
||||
``auto_continue`` keeps the PTY/limit hook installed for one-shot runs so
|
||||
``--auto-continue`` / ``[agent] auto_continue_limits`` can raise limits
|
||||
without a prompt; otherwise non-interactive runs keep today's deny.
|
||||
without a prompt; otherwise non-interactive runs keep today's deny. The
|
||||
prompt-wait notifier only makes sense for a human at a terminal, so a
|
||||
non-interactive run clears it whatever ``[notify]`` says.
|
||||
"""
|
||||
from plyngent.cli.notify import install_notifier
|
||||
from plyngent.prompting import set_prompt_wait_hook
|
||||
|
||||
if interactive:
|
||||
_ = install_notifier(notify)
|
||||
else:
|
||||
set_prompt_wait_hook(None)
|
||||
if interactive or auto_continue:
|
||||
install_cli_limit_hooks()
|
||||
else:
|
||||
@@ -248,7 +258,7 @@ async def _run_chat( # noqa: C901, PLR0915 — chat orchestration
|
||||
_warn_recoverable_providers(store.recoverable_providers)
|
||||
|
||||
auto_effective = auto_continue or store.agent_config.auto_continue_limits
|
||||
_setup_hooks(interactive=interactive, auto_continue=auto_effective)
|
||||
_setup_hooks(interactive=interactive, auto_continue=auto_effective, notify=store.notify_config)
|
||||
# --yes forces sticky YOLO; else derive from config.confirm_destructive.
|
||||
from plyngent.cli.state import YoloMode
|
||||
|
||||
@@ -337,8 +347,10 @@ async def _run_chat( # noqa: C901, PLR0915 — chat orchestration
|
||||
quiet=quiet,
|
||||
mcp_manager=mcp_manager,
|
||||
)
|
||||
# Path denylist and policy confirm live on instance.workspace (no process bag).
|
||||
# Path denylist, trash switch 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.trash_enabled = store.trash_config.enabled
|
||||
from plyngent.tools.net import set_ssrf_assume_public_cidrs
|
||||
|
||||
# Clash Fake-IP etc.: treat configured CIDRs as public for fetch_local SSRF.
|
||||
|
||||
@@ -206,6 +206,19 @@ def _read_file_prefix(args_json: str) -> str:
|
||||
return _pretty_prefix(f"* Read '{path}' ")
|
||||
|
||||
|
||||
def _view_image_prefix(args_json: str) -> str:
|
||||
"""``* View image 'path' ``: the picture is already on its way to the model."""
|
||||
path = _json_str_arg(args_json, "path") or "?"
|
||||
return _pretty_prefix(f"* View image '{path}' ")
|
||||
|
||||
|
||||
def _view_image_detail(_args_json: str, result: str) -> str:
|
||||
"""``view_image`` outcome: the picture was attached, or why it was not."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result.partition('error: ')[2]})", "red"))
|
||||
return _pretty_segments(("(attached)", "green"))
|
||||
|
||||
|
||||
def _range_detail(result: str) -> str | None:
|
||||
"""``L{begin}-{end} (done)`` detail of a ranged result, when it carries one."""
|
||||
first = result.splitlines()[0] if result else ""
|
||||
@@ -537,6 +550,8 @@ def _mutator_detail(name: str, result: str) -> str:
|
||||
if match:
|
||||
chars, _path = match.groups()
|
||||
return _pretty_segments((f"({chars} chars)", None))
|
||||
if name == "delete_path" and "system trash" in result:
|
||||
return _pretty_segments(("(to trash)", "green"))
|
||||
return _pretty_segments(("(done)", "green"))
|
||||
|
||||
|
||||
@@ -896,6 +911,7 @@ def _skill_edit_detail(_args_json: str, result: str) -> str:
|
||||
# Pretty tools: ``prefix`` prints as the call starts, ``detail`` when it lands.
|
||||
_PRETTY_LINES: dict[str, PrettyLine] = {
|
||||
"read_file": PrettyLine(prefix=_read_file_prefix, detail=_read_file_detail),
|
||||
"view_image": PrettyLine(prefix=_view_image_prefix, detail=_view_image_detail),
|
||||
"tree": PrettyLine(prefix=_tree_prefix, detail=_tree_detail),
|
||||
"todo_list": _todo_line("todo_list"),
|
||||
"todo_push": _todo_line("todo_push"),
|
||||
|
||||
@@ -10,6 +10,7 @@ from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantFunctionToolCall,
|
||||
ToolChatMessage,
|
||||
UserChatMessage,
|
||||
content_to_text,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -78,7 +79,7 @@ def format_session_export_md(
|
||||
|
||||
def _format_message_md(message: AnyChatMessage) -> list[str]:
|
||||
if isinstance(message, UserChatMessage):
|
||||
return ["## user", "", message.content]
|
||||
return ["## user", "", content_to_text(message.content)]
|
||||
if isinstance(message, AssistantChatMessage):
|
||||
parts: list[str] = ["## assistant", ""]
|
||||
reasoning = message.reasoning_content
|
||||
|
||||
+149
-47
@@ -4,6 +4,7 @@ import contextlib
|
||||
import selectors
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
@@ -13,6 +14,7 @@ from plyngent.cli.interrupt import off_loop_prompt, pause_task_cancel_for_prompt
|
||||
from plyngent.prompting import (
|
||||
ChoiceOption,
|
||||
NonInteractiveError,
|
||||
PromptAnswerSource,
|
||||
ask,
|
||||
ask_async,
|
||||
choose,
|
||||
@@ -20,6 +22,7 @@ from plyngent.prompting import (
|
||||
configure_prompting,
|
||||
confirm,
|
||||
get_prompt_backend,
|
||||
prompt_wait,
|
||||
set_prompt_output_hook,
|
||||
)
|
||||
from plyngent.tools.access import AccessDecision
|
||||
@@ -30,12 +33,37 @@ from plyngent.tools.workspace import AccessMode, parse_access_mode
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"]
|
||||
|
||||
# Answers a notification offers (see `cli/notify.py`) mapped onto the keys the
|
||||
# prompts below already accept, so one answer source fits every confirm.
|
||||
_NOTIFY_ANSWERS = {"allow": "y", "deny": "n"}
|
||||
|
||||
_BOX_MIN_WIDTH = 40
|
||||
_BOX_MAX_WIDTH = 100
|
||||
_BOX_PAD = 2 # spaces inside left/right borders
|
||||
|
||||
# How long one poll slice waits before a pending notification answer is checked.
|
||||
_ANSWER_POLL_SECONDS = 0.1
|
||||
|
||||
|
||||
def _answer_source(wait: PromptAnswerSource | None) -> PromptAnswerSource | None:
|
||||
"""The wait's answer source, but only when the prompt offered answers."""
|
||||
return wait if wait is not None and wait.supports_actions else None
|
||||
|
||||
|
||||
def _read_answer(
|
||||
timeout_seconds: float | None,
|
||||
wait: PromptAnswerSource | None,
|
||||
) -> str | None:
|
||||
"""One answer line, from stdin or the notification (``None`` on timeout)."""
|
||||
raw = _read_yes_no_line_with_timeout(timeout_seconds, source=_answer_source(wait))
|
||||
if raw is None:
|
||||
return None
|
||||
return _NOTIFY_ANSWERS.get(raw.strip().lower(), raw)
|
||||
|
||||
|
||||
# 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"})
|
||||
@@ -174,8 +202,9 @@ def _decide_continue_limit(reason: str) -> bool:
|
||||
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()
|
||||
with prompt_wait("limit", "limit reached — continue?", reason):
|
||||
_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
|
||||
@@ -205,12 +234,33 @@ async def prompt_continue_limit_async(reason: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _confirm_with(wait: PromptAnswerSource | None, prompt: str, *, default: bool) -> bool:
|
||||
"""Yes/no, also taking an answer that arrived through a notification."""
|
||||
source = _answer_source(wait)
|
||||
if source is None:
|
||||
return confirm(prompt, default=default)
|
||||
suffix = "[Y/n]" if default else "[y/N]"
|
||||
with contextlib.suppress(OSError):
|
||||
_ = sys.stderr.write(f"{prompt} {suffix}: ")
|
||||
_ = sys.stderr.flush()
|
||||
try:
|
||||
raw = _read_answer(None, wait)
|
||||
except (KeyboardInterrupt, EOFError) as exc:
|
||||
msg = "prompt cancelled"
|
||||
raise NonInteractiveError(msg) from exc
|
||||
token = (raw or "").strip().lower()
|
||||
if not token:
|
||||
return default
|
||||
return token in {"y", "yes"}
|
||||
|
||||
|
||||
def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool | str:
|
||||
"""True allow; False deny; non-empty str = deny with comment for the model."""
|
||||
del args
|
||||
try:
|
||||
_echo_tool_confirm(name, reason)
|
||||
allowed = confirm("Allow this tool call?", default=False)
|
||||
with prompt_wait("confirm", f"tool {name!r} needs confirmation", reason) as wait:
|
||||
_echo_tool_confirm(name, reason)
|
||||
allowed = _confirm_with(wait, "Allow this tool call?", default=False)
|
||||
except NonInteractiveError:
|
||||
return False
|
||||
if allowed:
|
||||
@@ -242,34 +292,64 @@ def _try_readline() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _read_yes_no_line_with_timeout(timeout_seconds: float) -> str | None:
|
||||
"""Read one line from stdin with *timeout_seconds*; ``None`` on timeout/EOF.
|
||||
|
||||
Uses :mod:`selectors` on the process stdin FD. On Windows without selector
|
||||
support, falls back to blocking readline (no true timeout).
|
||||
"""
|
||||
def _stdin_selector() -> selectors.BaseSelector | None:
|
||||
"""A selector watching process stdin, or ``None`` when it cannot be polled."""
|
||||
try:
|
||||
if not sys.stdin.isatty():
|
||||
return None
|
||||
fd = sys.stdin.fileno()
|
||||
except OSError, ValueError, AttributeError:
|
||||
return _try_readline()
|
||||
|
||||
try:
|
||||
selector = selectors.DefaultSelector()
|
||||
_ = selector.register(fd, selectors.EVENT_READ)
|
||||
except OSError, ValueError, AttributeError:
|
||||
return None
|
||||
return selector
|
||||
|
||||
|
||||
def _read_yes_no_line_with_timeout(
|
||||
timeout_seconds: float | None,
|
||||
*,
|
||||
source: PromptAnswerSource | None = None,
|
||||
) -> str | None:
|
||||
"""Read one line from stdin with *timeout_seconds*; ``None`` on timeout/EOF.
|
||||
|
||||
Polls the process stdin FD in short slices, so a *source* (a notification
|
||||
answer) can end the read too; stdin wins whenever it becomes readable. A
|
||||
``None`` timeout waits for a line or an answer, whichever comes first; with
|
||||
neither stdin nor a *source* pollable the read gives up at once.
|
||||
On Windows without selector support, falls back to blocking readline (no
|
||||
true timeout, and no answers from a *source*).
|
||||
"""
|
||||
try:
|
||||
if not sys.stdin.isatty():
|
||||
return None if source is None else source.take()
|
||||
except OSError, ValueError, AttributeError:
|
||||
return _try_readline()
|
||||
|
||||
selector = _stdin_selector()
|
||||
if selector is None:
|
||||
return _try_readline()
|
||||
|
||||
deadline = None if timeout_seconds is None else time.monotonic() + timeout_seconds
|
||||
result: str | None = None
|
||||
try:
|
||||
events = selector.select(timeout_seconds)
|
||||
if not events:
|
||||
return None
|
||||
return sys.stdin.readline()
|
||||
except OSError, ValueError, EOFError:
|
||||
return None
|
||||
while True:
|
||||
if deadline is None:
|
||||
remaining = _ANSWER_POLL_SECONDS
|
||||
else:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
if selector.select(min(remaining, _ANSWER_POLL_SECONDS)):
|
||||
try:
|
||||
result = sys.stdin.readline()
|
||||
except OSError, EOFError:
|
||||
result = None
|
||||
break
|
||||
answer = None if source is None else source.take()
|
||||
if answer is not None:
|
||||
result = answer
|
||||
break
|
||||
finally:
|
||||
selector.close()
|
||||
return result
|
||||
|
||||
|
||||
def prompt_policy_command_confirm(
|
||||
@@ -290,7 +370,10 @@ def prompt_policy_command_confirm(
|
||||
f"(timeout {timeout_seconds:g}s defaults to DENY; not skipped by YOLO)"
|
||||
)
|
||||
try:
|
||||
with pause_task_cancel_for_prompt():
|
||||
with (
|
||||
pause_task_cancel_for_prompt(),
|
||||
prompt_wait("policy", f"allow command {basename!r}?", reason) as wait,
|
||||
):
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
return False
|
||||
@@ -301,7 +384,7 @@ def prompt_policy_command_confirm(
|
||||
with contextlib.suppress(OSError):
|
||||
_ = sys.stderr.write(prompt)
|
||||
_ = sys.stderr.flush()
|
||||
raw = _read_yes_no_line_with_timeout(timeout_seconds)
|
||||
raw = _read_answer(timeout_seconds, wait)
|
||||
if raw is None:
|
||||
with contextlib.suppress(OSError, NonInteractiveError):
|
||||
backend.secho(
|
||||
@@ -334,7 +417,10 @@ def prompt_policy_fetch_confirm(
|
||||
f"(timeout {timeout_seconds:g}s defaults to DENY; not skipped by YOLO)"
|
||||
)
|
||||
try:
|
||||
with pause_task_cancel_for_prompt():
|
||||
with (
|
||||
pause_task_cancel_for_prompt(),
|
||||
prompt_wait("policy", f"allow fetch_local {host!r}?", reason) as wait,
|
||||
):
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
return False
|
||||
@@ -346,7 +432,7 @@ def prompt_policy_fetch_confirm(
|
||||
with contextlib.suppress(OSError):
|
||||
_ = sys.stderr.write(prompt)
|
||||
_ = sys.stderr.flush()
|
||||
raw = _read_yes_no_line_with_timeout(timeout_seconds)
|
||||
raw = _read_answer(timeout_seconds, wait)
|
||||
if raw is None:
|
||||
with contextlib.suppress(OSError, NonInteractiveError):
|
||||
backend.secho(
|
||||
@@ -380,7 +466,10 @@ def prompt_skill_write_confirm(
|
||||
f"(timeout {timeout_seconds:g}s defaults to DENY; not skipped by YOLO)",
|
||||
]
|
||||
try:
|
||||
with pause_task_cancel_for_prompt():
|
||||
with (
|
||||
pause_task_cancel_for_prompt(),
|
||||
prompt_wait("skills", f"allow {action} in {skill_dir.name!r}?", detail) as wait,
|
||||
):
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
return SkillWriteDecision(allow=False)
|
||||
@@ -393,7 +482,7 @@ def prompt_skill_write_confirm(
|
||||
with contextlib.suppress(OSError):
|
||||
_ = sys.stderr.write(prompt)
|
||||
_ = sys.stderr.flush()
|
||||
raw = _read_yes_no_line_with_timeout(timeout_seconds)
|
||||
raw = _read_answer(timeout_seconds, wait)
|
||||
if raw is None:
|
||||
with contextlib.suppress(OSError, NonInteractiveError):
|
||||
backend.secho(f"[skills] timed out after {timeout_seconds:g}s — denied", fg="red", err=True)
|
||||
@@ -428,7 +517,10 @@ def prompt_directory_access_confirm(
|
||||
lines.append(f"why: {reason}")
|
||||
lines.append(f"(timeout {timeout_seconds:g}s defaults to DENY)")
|
||||
try:
|
||||
with pause_task_cancel_for_prompt():
|
||||
with (
|
||||
pause_task_cancel_for_prompt(),
|
||||
prompt_wait("access", f"allow {mode.name.lower()} access to {path}?", reason) as wait,
|
||||
):
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
return None
|
||||
@@ -444,7 +536,7 @@ def prompt_directory_access_confirm(
|
||||
with contextlib.suppress(OSError):
|
||||
_ = sys.stderr.write(prompt)
|
||||
_ = sys.stderr.flush()
|
||||
raw = _read_yes_no_line_with_timeout(timeout_seconds)
|
||||
raw = _read_answer(timeout_seconds, wait)
|
||||
if raw is None:
|
||||
with contextlib.suppress(OSError, NonInteractiveError):
|
||||
backend.secho(
|
||||
@@ -470,8 +562,9 @@ async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reaso
|
||||
try:
|
||||
# Box is printed inside the worker thread via confirm's backend.
|
||||
def _run() -> bool:
|
||||
_echo_tool_confirm(name, reason)
|
||||
return confirm("Allow this tool call?", default=False)
|
||||
with prompt_wait("confirm", f"tool {name!r} needs confirmation", reason) as wait:
|
||||
_echo_tool_confirm(name, reason)
|
||||
return _confirm_with(wait, "Allow this tool call?", default=False)
|
||||
|
||||
from plyngent.prompting import run_prompt_async
|
||||
|
||||
@@ -512,21 +605,30 @@ def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchC
|
||||
if not backend.is_interactive():
|
||||
# Non-interactive: deny, like the default of the single prompt.
|
||||
return BatchConfirmDecision(allowed=False)
|
||||
backend.echo()
|
||||
backend.secho(format_batch_confirm_box(requests), fg="yellow")
|
||||
backend.secho("m = review them one call at a time", fg="bright_black")
|
||||
backend.echo()
|
||||
try:
|
||||
token = (
|
||||
backend.read_line(
|
||||
f"Allow these {len(requests)} tool calls? [y/N/m]:",
|
||||
default="n",
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
except NonInteractiveError:
|
||||
return BatchConfirmDecision(allowed=False)
|
||||
names = ", ".join(request.name for request in requests)
|
||||
with prompt_wait("batch", f"{len(requests)} tool calls need confirmation", names) as wait:
|
||||
backend.echo()
|
||||
backend.secho(format_batch_confirm_box(requests), fg="yellow")
|
||||
backend.secho("m = review them one call at a time", fg="bright_black")
|
||||
backend.echo()
|
||||
source = _answer_source(wait)
|
||||
try:
|
||||
if source is None:
|
||||
token = (
|
||||
backend.read_line(
|
||||
f"Allow these {len(requests)} tool calls? [y/N/m]:",
|
||||
default="n",
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
else:
|
||||
with contextlib.suppress(OSError):
|
||||
_ = sys.stderr.write(f"Allow these {len(requests)} tool calls? [y/N]: ")
|
||||
_ = sys.stderr.flush()
|
||||
token = (_read_answer(None, wait) or "n").strip().lower()
|
||||
except NonInteractiveError:
|
||||
return BatchConfirmDecision(allowed=False)
|
||||
if token in _BATCH_MANUAL_TOKENS:
|
||||
return BatchConfirmDecision(allowed=False, each=True)
|
||||
if token in _BATCH_YES_TOKENS:
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""``@path`` mentions in a REPL line: attach the file the line refers to.
|
||||
|
||||
A mention starts a whitespace-delimited token, so an e-mail address (`a@b`) is
|
||||
not one. Both spellings work::
|
||||
|
||||
look at @shot.png and @src/plyngent/cli/repl.py
|
||||
look at @"screens/my shot.png"
|
||||
|
||||
The path resolves against the working directory (``~`` expands) and is checked
|
||||
against the path denylist, like every tool path. What is attached depends on
|
||||
the file:
|
||||
|
||||
- an image (JPEG, PNG, GIF, WebP, sniffed from the bytes) becomes an image part,
|
||||
so the model sees the picture itself. A model without
|
||||
``[providers.*.models.*].image_in`` skips it with a note instead of letting
|
||||
the provider answer 400.
|
||||
- any other file is read as text and appended to the message under a
|
||||
``--- @path ---`` header, capped like a tool result; the remainder stays
|
||||
resumable through the ``truncate_token`` in the marker.
|
||||
|
||||
Images and text both keep the typed ``@path`` in the message, so the model can
|
||||
tell which attachment is which.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
|
||||
from plyngent.agent.images import image_part_from_bytes, sniff_image_media_type
|
||||
from plyngent.tools.truncate_token import truncate_to_memory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from plyngent.cli.state import ReplState
|
||||
from plyngent.lmproto.openai_compatible.model import ChatImagePart
|
||||
|
||||
MENTION = "@"
|
||||
# One attached text file is capped like a tool result, and a turn's attachments
|
||||
# together cannot outgrow this (the message is re-sent on every round).
|
||||
MAX_MENTION_FILE_CHARS = DEFAULT_TOOL_RESULT_MAX_CHARS
|
||||
MAX_MENTION_TOTAL_CHARS = 64_000
|
||||
MAX_PATH_COMPLETIONS = 100
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Mention:
|
||||
"""One ``@path`` reference as it was typed."""
|
||||
|
||||
token: str
|
||||
path: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MentionPayload:
|
||||
"""One turn's text plus what its mentions attached."""
|
||||
|
||||
text: str
|
||||
images: list[ChatImagePart] = field(default_factory=list)
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_mentions(line: str) -> list[Mention]:
|
||||
"""Every ``@path`` mention in *line*, in the order typed."""
|
||||
mentions: list[Mention] = []
|
||||
index = 0
|
||||
length = len(line)
|
||||
while index < length:
|
||||
char = line[index]
|
||||
if char != MENTION or (index > 0 and not line[index - 1].isspace()):
|
||||
index += 1
|
||||
continue
|
||||
start = index
|
||||
index += 1
|
||||
if index < length and line[index] == '"':
|
||||
end = line.find('"', index + 1)
|
||||
if end == -1:
|
||||
# An unterminated quote is still being typed: leave it alone.
|
||||
break
|
||||
path = line[index + 1 : end]
|
||||
index = end + 1
|
||||
else:
|
||||
while index < length and not line[index].isspace():
|
||||
index += 1
|
||||
path = line[start + 1 : index]
|
||||
if path and not path.startswith(MENTION):
|
||||
mentions.append(Mention(token=line[start:index], path=path))
|
||||
return mentions
|
||||
|
||||
|
||||
def mention_token_for(path: str) -> str:
|
||||
"""The ``@path`` spelling for *path* (quoted when it holds a space)."""
|
||||
if any(char.isspace() for char in path):
|
||||
return f'{MENTION}"{path}"'
|
||||
return f"{MENTION}{path}"
|
||||
|
||||
|
||||
def resolve_mentions(state: ReplState, line: str) -> MentionPayload:
|
||||
"""Attach what *line*'s mentions point at; notes say what was skipped."""
|
||||
payload = MentionPayload(text=line)
|
||||
mentions = parse_mentions(line)
|
||||
seen: set[str] = set()
|
||||
for mention in mentions:
|
||||
if mention.path in seen:
|
||||
continue
|
||||
seen.add(mention.path)
|
||||
budget = MAX_MENTION_TOTAL_CHARS - (len(payload.text) - len(line))
|
||||
note = _attach_mention(state, payload, mention, budget)
|
||||
if note is not None:
|
||||
payload.notes.append(note)
|
||||
return payload
|
||||
|
||||
|
||||
def _attach_mention( # noqa: PLR0911 — one early return per rejected file
|
||||
state: ReplState,
|
||||
payload: MentionPayload,
|
||||
mention: Mention,
|
||||
budget: int,
|
||||
) -> str | None:
|
||||
"""Attach one mention; a returned string is a note for the user."""
|
||||
target = _resolve(state, mention.path)
|
||||
if isinstance(target, str):
|
||||
return target
|
||||
if not target.exists():
|
||||
return f"{mention.token}: file not found"
|
||||
if not target.is_file():
|
||||
return f"{mention.token}: not a file"
|
||||
data = target.read_bytes()
|
||||
if sniff_image_media_type(data) is not None:
|
||||
return _attach_image(state, payload, mention, data)
|
||||
if b"\x00" in data[:512]:
|
||||
return f"{mention.token}: binary file skipped (only images and text are attached)"
|
||||
if budget <= 0:
|
||||
return f"{mention.token}: skipped, this turn's attachments are at the size cap"
|
||||
return _attach_text(payload, mention, data, budget)
|
||||
|
||||
|
||||
def _resolve(state: ReplState, path: str) -> Path | str:
|
||||
"""The absolute path for a mention, or a note when it is denylisted."""
|
||||
from plyngent.tools.context import bind_instance
|
||||
from plyngent.tools.workspace import denylist_match
|
||||
|
||||
target = Path(path).expanduser()
|
||||
if not target.is_absolute():
|
||||
target = Path.cwd() / target
|
||||
with bind_instance(state.instance_state):
|
||||
denied = denylist_match(target)
|
||||
if denied is not None:
|
||||
return f"@{path}: matches the path denylist pattern {denied!r}"
|
||||
return target
|
||||
|
||||
|
||||
def _attach_image(
|
||||
state: ReplState,
|
||||
payload: MentionPayload,
|
||||
mention: Mention,
|
||||
data: bytes,
|
||||
) -> str | None:
|
||||
"""Attach an image part, or explain why this turn cannot carry it."""
|
||||
from plyngent.config.routing import model_config_for
|
||||
|
||||
model_config = model_config_for(state.provider, state.model)
|
||||
if model_config is None or not model_config.image_in:
|
||||
return (
|
||||
f"{mention.token}: skipped, model {state.model!r} does not accept image input "
|
||||
f'(set image_in = true under [providers.{state.provider_name}.models."{state.model}"])'
|
||||
)
|
||||
part = image_part_from_bytes(data, detail="auto")
|
||||
if isinstance(part, str):
|
||||
return f"{mention.token}: {part.removeprefix('error: ')}"
|
||||
payload.images.append(part)
|
||||
return None
|
||||
|
||||
|
||||
def _attach_text(payload: MentionPayload, mention: Mention, data: bytes, budget: int) -> str | None:
|
||||
"""Append a text file to the message under its own header."""
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
# ``truncate_to_memory`` embeds the resumable token and marker itself.
|
||||
body, token = truncate_to_memory(text, min(MAX_MENTION_FILE_CHARS, budget))
|
||||
header = f"--- {mention.token} ({len(text)} chars{', truncated' if token is not None else ''}) ---"
|
||||
payload.text = f"{payload.text}\n\n{header}\n{body}"
|
||||
return None
|
||||
|
||||
|
||||
def complete_mention_paths(incomplete: str, *, cwd: Path | None = None) -> list[str]:
|
||||
"""Path candidates for the path part of a mention (directories end in ``/``)."""
|
||||
base = cwd if cwd is not None else Path.cwd()
|
||||
prefix = incomplete.rpartition("/")[2]
|
||||
raw_dir = incomplete[: len(incomplete) - len(prefix)]
|
||||
directory = Path(raw_dir).expanduser() if raw_dir else Path()
|
||||
if not directory.is_absolute():
|
||||
directory = base / directory
|
||||
try:
|
||||
entries = sorted(os.scandir(directory), key=lambda entry: entry.name)
|
||||
except OSError:
|
||||
return []
|
||||
completions: list[str] = []
|
||||
for entry in entries:
|
||||
if prefix and not entry.name.startswith(prefix):
|
||||
continue
|
||||
if not prefix and entry.name.startswith("."):
|
||||
continue
|
||||
suffix = "/" if entry.is_dir() else ""
|
||||
completions.append(f"{raw_dir}{entry.name}{suffix}")
|
||||
if len(completions) >= MAX_PATH_COMPLETIONS:
|
||||
break
|
||||
return completions
|
||||
|
||||
|
||||
def mention_completions(token: str, *, cwd: Path | None = None) -> list[str]:
|
||||
"""Complete an ``@path`` token (including its ``@``) for readline."""
|
||||
if not token.startswith(MENTION):
|
||||
return []
|
||||
paths = complete_mention_paths(token[len(MENTION) :], cwd=cwd)
|
||||
return [mention_token_for(path) for path in paths]
|
||||
@@ -0,0 +1,501 @@
|
||||
"""Notify the human when a prompt is waiting on them.
|
||||
|
||||
A prompt blocks the turn, so someone who switched to another window learns about
|
||||
it only when they come back. The notifier announces a prompt once it has waited
|
||||
``grace_seconds`` — answering at the terminal stays silent — and, where the
|
||||
platform allows it, offers the answer itself: allow / deny / review buttons for
|
||||
a confirmation, a reply field for a question.
|
||||
|
||||
Two channels. The desktop one is the optional ``desktop-notifier`` extra
|
||||
(``pip install plyngent[notify]``); the terminal one writes an OSC escape
|
||||
sequence to the controlling terminal, which every modern terminal turns into a
|
||||
native notification without any dependency at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from queue import Empty, SimpleQueue
|
||||
from typing import TYPE_CHECKING, Any, Protocol, Self, TypeVar, runtime_checkable
|
||||
|
||||
from plyngent.prompting import PromptWait, get_prompt_backend, set_prompt_wait_hook
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Coroutine, Mapping
|
||||
from types import TracebackType
|
||||
|
||||
from plyngent.config.models import NotifyConfig
|
||||
|
||||
ALLOW = "allow"
|
||||
DENY = "deny"
|
||||
REVIEW = "review"
|
||||
|
||||
#: Kinds whose notification carries answer buttons (``ask`` gets a reply field).
|
||||
_ANSWER_KINDS = frozenset({"confirm", "batch", "policy", "access", "skills"})
|
||||
|
||||
_BUTTONS: tuple[tuple[str, str], ...] = (
|
||||
(ALLOW, "Allow once"),
|
||||
(DENY, "Deny"),
|
||||
(REVIEW, "Go to terminal"),
|
||||
)
|
||||
|
||||
_MAX_TITLE = 80
|
||||
_MAX_MESSAGE = 200
|
||||
|
||||
|
||||
def _clean(text: str, limit: int) -> str:
|
||||
"""One line, no control characters, truncated to *limit* characters."""
|
||||
flat = " ".join(str(text).split())
|
||||
stripped = flat.replace("\x1b", "").replace("\x07", "")
|
||||
return stripped if len(stripped) <= limit else stripped[: limit - 1] + "…"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Desktop channel (optional `desktop-notifier` extra)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DesktopRequest:
|
||||
"""One notification to show on the desktop, with the answers it offers."""
|
||||
|
||||
title: str
|
||||
message: str
|
||||
buttons: tuple[tuple[str, str], ...] = ()
|
||||
reply_field: bool = False
|
||||
on_answer: Callable[[str], None] | None = None
|
||||
on_reply: Callable[[str], None] | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DesktopChannel(Protocol):
|
||||
"""A desktop notification surface (the extra, or a test double)."""
|
||||
|
||||
def capabilities(self) -> frozenset[str]:
|
||||
"""``buttons`` / ``reply`` when answers can be offered."""
|
||||
...
|
||||
|
||||
def send(self, request: DesktopRequest) -> str | None:
|
||||
"""Show *request*; the returned id dismisses it again."""
|
||||
...
|
||||
|
||||
def clear(self, identifier: str | None) -> None:
|
||||
"""Dismiss a notification (a no-op for an unknown id)."""
|
||||
...
|
||||
|
||||
|
||||
class DesktopNotifierChannel:
|
||||
"""``desktop-notifier`` on its own loop in a daemon thread.
|
||||
|
||||
A notification's callbacks only fire while the event loop that owns it is
|
||||
running, which the library's synchronous wrapper does not provide — the
|
||||
async API therefore runs here on a dedicated loop, and the prompt thread
|
||||
waits on the results.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._loop: asyncio.AbstractEventLoop = asyncio.new_event_loop()
|
||||
self._started: threading.Event = threading.Event()
|
||||
self._notifier: object = None
|
||||
self._capabilities: frozenset[str] = frozenset()
|
||||
self._thread: threading.Thread = threading.Thread(target=self._serve, name="plyngent-notify", daemon=True)
|
||||
self._thread.start()
|
||||
_ = self._started.wait(5.0)
|
||||
|
||||
def _serve(self) -> None:
|
||||
asyncio.set_event_loop(self._loop)
|
||||
try:
|
||||
self._loop.run_until_complete(self._init())
|
||||
except Exception: # noqa: BLE001 - absent extra, or a platform that refuses
|
||||
self._notifier = None
|
||||
self._capabilities = frozenset()
|
||||
finally:
|
||||
self._started.set()
|
||||
self._loop.run_forever()
|
||||
|
||||
async def _init(self) -> None:
|
||||
from desktop_notifier import (
|
||||
Capability,
|
||||
DesktopNotifier,
|
||||
)
|
||||
|
||||
notifier = DesktopNotifier(app_name="plyngent")
|
||||
capabilities = await notifier.get_capabilities()
|
||||
names: set[str] = set()
|
||||
if Capability.BUTTONS in capabilities:
|
||||
names.add("buttons")
|
||||
if Capability.REPLY_FIELD in capabilities:
|
||||
names.add("reply")
|
||||
self._notifier = notifier
|
||||
self._capabilities = frozenset(names)
|
||||
|
||||
def _call(self, coro: Coroutine[Any, Any, _T]) -> _T:
|
||||
"""Run *coro* on the channel's loop and wait for its result."""
|
||||
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
||||
return future.result(5.0)
|
||||
|
||||
def capabilities(self) -> frozenset[str]:
|
||||
return self._capabilities
|
||||
|
||||
def send(self, request: DesktopRequest) -> str | None:
|
||||
if self._notifier is None:
|
||||
msg = "no desktop notifier"
|
||||
raise RuntimeError(msg)
|
||||
from desktop_notifier import Button, ReplyField
|
||||
|
||||
buttons = [
|
||||
Button(title=label, on_pressed=_button_handler(request.on_answer, key)) for key, label in request.buttons
|
||||
]
|
||||
reply = ReplyField(title="Answer", on_replied=_reply_handler(request.on_reply)) if request.reply_field else None
|
||||
send = getattr(self._notifier, "send") # noqa: B009 - typed at runtime
|
||||
return str(
|
||||
self._call(
|
||||
send(
|
||||
title=request.title,
|
||||
message=request.message,
|
||||
buttons=buttons,
|
||||
reply_field=reply,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def clear(self, identifier: str | None) -> None:
|
||||
if identifier is None or self._notifier is None:
|
||||
return
|
||||
with contextlib.suppress(Exception):
|
||||
clear = getattr(self._notifier, "clear") # noqa: B009 - typed at runtime
|
||||
_ = self._call(clear(identifier))
|
||||
|
||||
|
||||
def _button_handler(callback: Callable[[str], None] | None, key: str) -> Callable[[], None]:
|
||||
"""A button callback carrying the answer *key*."""
|
||||
|
||||
def _run() -> None:
|
||||
if callback is not None:
|
||||
callback(key)
|
||||
|
||||
return _run
|
||||
|
||||
|
||||
def _reply_handler(callback: Callable[[str], None] | None) -> Callable[[str], None]:
|
||||
"""A reply-field callback carrying the typed text."""
|
||||
|
||||
def _run(text: str) -> None:
|
||||
if callback is not None:
|
||||
callback(text)
|
||||
|
||||
return _run
|
||||
|
||||
|
||||
def load_desktop_channel() -> DesktopChannel | None:
|
||||
"""The desktop channel, or ``None`` when the extra is not installed."""
|
||||
if importlib.util.find_spec("desktop_notifier") is None:
|
||||
return None
|
||||
channel = DesktopNotifierChannel()
|
||||
return channel if channel.capabilities() else None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Terminal channel (no dependency)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_OSC9 = "\x1b]9;{title}: {message}\x07"
|
||||
_OSC777 = "\x1b]777;notify;{title};{message}\x07"
|
||||
_BELL = "\a"
|
||||
#: VTE (GNOME Terminal, Tilix, Terminator, …) speaks OSC 777 from this version on.
|
||||
_VTE_OSC777 = 4600
|
||||
|
||||
|
||||
def _vte_version(env: Mapping[str, str]) -> int:
|
||||
try:
|
||||
return int(env.get("VTE_VERSION", "0"))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def detect_protocol(env: Mapping[str, str], override: str = "") -> str:
|
||||
"""Pick the terminal escape protocol: ``osc777``, ``osc9`` or ``bell``.
|
||||
|
||||
Detection is environment-only — no capability query — so it never blocks and
|
||||
never touches the input stream. kitty speaks OSC 99 only, which this module
|
||||
does not implement, so it keeps the plain bell.
|
||||
"""
|
||||
forced = override.strip().lower()
|
||||
if forced in {"osc777", "osc9", "bell"}:
|
||||
return forced
|
||||
program = env.get("TERM_PROGRAM", "")
|
||||
if env.get("KITTY_WINDOW_ID"):
|
||||
return "bell"
|
||||
if program in {"iTerm.app", "Apple_Terminal"}:
|
||||
return "osc9"
|
||||
if program in {"WezTerm", "ghostty"} or env.get("WT_SESSION"):
|
||||
return "osc777"
|
||||
if _vte_version(env) >= _VTE_OSC777:
|
||||
return "osc777"
|
||||
term = env.get("TERM", "")
|
||||
return "osc777" if any(hint in term for hint in ("wezterm", "ghostty", "rxvt")) else "bell"
|
||||
|
||||
|
||||
def detect_multiplexer(env: Mapping[str, str]) -> str:
|
||||
"""``tmux`` / ``screen`` when the sequence must be wrapped, else ``""``."""
|
||||
if env.get("TMUX"):
|
||||
return "tmux"
|
||||
return "screen" if env.get("STY") else ""
|
||||
|
||||
|
||||
def terminal_sequence(protocol: str, title: str, message: str, *, multiplexer: str = "") -> str:
|
||||
"""The escape sequence for one notification, wrapped for a multiplexer."""
|
||||
if protocol == "bell":
|
||||
return _BELL
|
||||
safe_title = _clean(title, _MAX_TITLE)
|
||||
safe_message = _clean(message, _MAX_MESSAGE)
|
||||
if protocol == "osc9":
|
||||
seq = _OSC9.format(title=safe_title, message=safe_message)
|
||||
else:
|
||||
seq = _OSC777.format(title=safe_title, message=safe_message)
|
||||
if multiplexer == "tmux":
|
||||
# tmux needs `set -g allow-passthrough on` and an ESC-prefixed payload.
|
||||
return "\x1bPtmux;\x1b" + seq.replace("\x1b", "\x1b\x1b") + "\x1b\\"
|
||||
if multiplexer == "screen":
|
||||
return "\x1bP" + seq + "\x1b\\"
|
||||
return seq
|
||||
|
||||
|
||||
def write_terminal(data: str) -> None:
|
||||
"""Write *data* to the controlling terminal; never raises.
|
||||
|
||||
``/dev/tty`` rather than stdout/stderr so a redirect cannot swallow the
|
||||
notification or mix it into the program's output.
|
||||
"""
|
||||
payload = data.encode("utf-8", "replace")
|
||||
if sys.platform != "win32":
|
||||
try:
|
||||
with Path("/dev/tty").open("wb") as tty:
|
||||
_ = tty.write(payload)
|
||||
tty.flush()
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
return
|
||||
buffer = getattr(sys.stderr, "buffer", None)
|
||||
if buffer is None:
|
||||
return
|
||||
with contextlib.suppress(OSError, ValueError):
|
||||
_ = buffer.write(payload)
|
||||
_ = sys.stderr.flush()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The notifier
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _Resolve:
|
||||
"""Sentinel: resolve the desktop channel lazily from the extra."""
|
||||
|
||||
|
||||
_RESOLVE = _Resolve()
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
class WaitHandle:
|
||||
"""One waiting prompt: its grace timer, its answers, its notification."""
|
||||
|
||||
notifier: WaitNotifier
|
||||
wait: PromptWait
|
||||
identifier: str | None
|
||||
#: Whether the notification offered answers, so a read may poll for them.
|
||||
supports_actions: bool
|
||||
|
||||
def __init__(self, notifier: WaitNotifier, wait: PromptWait) -> None:
|
||||
self.notifier = notifier
|
||||
self.wait = wait
|
||||
self.identifier = None
|
||||
self.supports_actions = False
|
||||
self._answers: SimpleQueue[str] = SimpleQueue()
|
||||
self._timer: threading.Timer | None = None
|
||||
self._closed: bool = False
|
||||
|
||||
def take(self) -> str | None:
|
||||
"""The next answer offered by the notification, without blocking."""
|
||||
try:
|
||||
return self._answers.get_nowait()
|
||||
except Empty:
|
||||
return None
|
||||
|
||||
def answer(self, key: str) -> None:
|
||||
"""Record one answer from the notification (a button, or a reply)."""
|
||||
if self._closed:
|
||||
return
|
||||
if key == REVIEW and self.wait.kind != "ask":
|
||||
# "review" means "I will answer in the terminal": withdraw and wait.
|
||||
self.notifier.dismiss(self)
|
||||
return
|
||||
self._answers.put(key)
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
timer = threading.Timer(self.notifier.grace_seconds, self._notify)
|
||||
timer.daemon = True
|
||||
self._timer = timer
|
||||
timer.start()
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> None:
|
||||
del exc_type, exc, tb
|
||||
self._closed = True
|
||||
if self._timer is not None:
|
||||
self._timer.cancel()
|
||||
self.notifier.dismiss(self)
|
||||
|
||||
def _notify(self) -> None:
|
||||
if self._closed or not self.notifier.may_notify():
|
||||
return
|
||||
if not self.notifier.notify_desktop(self):
|
||||
self.notifier.notify_terminal(self.wait)
|
||||
|
||||
|
||||
class WaitNotifier:
|
||||
"""Announce a waiting prompt once it has waited long enough.
|
||||
|
||||
Instances are the ``prompt_wait`` hook: calling one returns the
|
||||
:class:`WaitHandle` context manager the prompt read consults.
|
||||
"""
|
||||
|
||||
_config: NotifyConfig
|
||||
_resolve_desktop: bool
|
||||
_desktop: DesktopChannel | None
|
||||
_write: Callable[[str], None]
|
||||
_env: Mapping[str, str]
|
||||
_clock: Callable[[], float]
|
||||
_last_sent: float
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: NotifyConfig,
|
||||
*,
|
||||
desktop: DesktopChannel | _Resolve | None = _RESOLVE,
|
||||
write: Callable[[str], None] | None = None,
|
||||
env: Mapping[str, str] | None = None,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self._config = config
|
||||
if isinstance(desktop, _Resolve):
|
||||
self._resolve_desktop = True
|
||||
self._desktop = None
|
||||
else:
|
||||
self._resolve_desktop = False
|
||||
self._desktop = desktop
|
||||
self._write = write or write_terminal
|
||||
self._env = os.environ if env is None else env
|
||||
self._clock = clock
|
||||
self._last_sent = float("-inf")
|
||||
|
||||
# -- hook ------------------------------------------------------------- #
|
||||
|
||||
def __call__(self, wait: PromptWait) -> WaitHandle:
|
||||
return WaitHandle(self, wait)
|
||||
|
||||
@property
|
||||
def grace_seconds(self) -> float:
|
||||
return self._config.grace_seconds
|
||||
|
||||
def _wants(self, channel: str) -> bool:
|
||||
channels = self._config.channels
|
||||
return channel in channels or "auto" in channels
|
||||
|
||||
def may_notify(self) -> bool:
|
||||
"""Whether a notification is worth sending right now."""
|
||||
if not self._config.enabled or not get_prompt_backend().is_interactive():
|
||||
return False
|
||||
now = self._clock()
|
||||
if now - self._last_sent < self._config.min_interval_seconds:
|
||||
return False
|
||||
self._last_sent = now
|
||||
return True
|
||||
|
||||
def _message(self, wait: PromptWait) -> str:
|
||||
title = _clean(wait.title, _MAX_TITLE)
|
||||
if self._config.include_reason and wait.detail:
|
||||
return f"{title}\n{_clean(wait.detail, _MAX_MESSAGE)}"
|
||||
return title
|
||||
|
||||
def desktop_channel(self) -> DesktopChannel | None:
|
||||
"""The desktop surface, resolved once (importing the extra on demand)."""
|
||||
if self._resolve_desktop:
|
||||
self._desktop = load_desktop_channel()
|
||||
self._resolve_desktop = False
|
||||
return self._desktop
|
||||
|
||||
def notify_desktop(self, handle: WaitHandle) -> bool:
|
||||
"""Show the desktop notification; False when it could not be shown."""
|
||||
if not self._wants("desktop"):
|
||||
return False
|
||||
channel = self.desktop_channel()
|
||||
if channel is None:
|
||||
return False
|
||||
wait = handle.wait
|
||||
capabilities = channel.capabilities()
|
||||
buttons: tuple[tuple[str, str], ...] = ()
|
||||
if self._config.actions and wait.kind in _ANSWER_KINDS and "buttons" in capabilities:
|
||||
buttons = _BUTTONS
|
||||
reply = self._config.actions and wait.kind == "ask" and "reply" in capabilities
|
||||
try:
|
||||
handle.identifier = channel.send(
|
||||
DesktopRequest(
|
||||
title=_clean(wait.title, _MAX_TITLE),
|
||||
message=self._message(wait),
|
||||
buttons=buttons,
|
||||
reply_field=reply,
|
||||
on_answer=handle.answer,
|
||||
on_reply=handle.answer,
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001 - a notification never breaks a prompt
|
||||
return False
|
||||
handle.supports_actions = bool(buttons) or reply
|
||||
return True
|
||||
|
||||
def notify_terminal(self, wait: PromptWait) -> None:
|
||||
"""Write one terminal notification (silent on any failure)."""
|
||||
if not self._wants("terminal"):
|
||||
return
|
||||
sequence = terminal_sequence(
|
||||
detect_protocol(self._env, self._config.protocol),
|
||||
wait.title,
|
||||
self._message(wait),
|
||||
multiplexer=detect_multiplexer(self._env),
|
||||
)
|
||||
self._write(sequence)
|
||||
|
||||
def dismiss(self, handle: WaitHandle) -> None:
|
||||
"""Dismiss the handle's desktop notification, if one is showing."""
|
||||
identifier = handle.identifier
|
||||
if identifier is None:
|
||||
return
|
||||
handle.identifier = None
|
||||
channel = self._desktop
|
||||
if channel is not None:
|
||||
channel.clear(identifier)
|
||||
|
||||
|
||||
def install_notifier(config: NotifyConfig) -> WaitNotifier | None:
|
||||
"""Install the prompt-wait hook from ``[notify]`` (None clears it)."""
|
||||
if not config.enabled:
|
||||
set_prompt_wait_hook(None)
|
||||
return None
|
||||
notifier = WaitNotifier(config)
|
||||
set_prompt_wait_hook(notifier)
|
||||
return notifier
|
||||
@@ -45,12 +45,16 @@ def build_completer(state: ReplState) -> Callable[[str, int], str | None]:
|
||||
def completer(text: str, state_index: int) -> str | None:
|
||||
import readline
|
||||
|
||||
from plyngent.cli.mentions import mention_completions
|
||||
from plyngent.cli.slash import complete_slash_args
|
||||
|
||||
buffer = readline.get_line_buffer()
|
||||
begidx = readline.get_begidx()
|
||||
# An ``@path`` mention completes files wherever it sits in the line.
|
||||
if text.startswith("@"):
|
||||
options = mention_completions(text)
|
||||
# Completing the first token (command).
|
||||
if begidx == 0 or (begidx > 0 and buffer[:begidx].strip() == ""):
|
||||
elif begidx == 0 or (begidx > 0 and buffer[:begidx].strip() == ""):
|
||||
options = filter_prefix(text, slash_commands())
|
||||
else:
|
||||
head = buffer[:begidx].strip()
|
||||
|
||||
@@ -34,6 +34,19 @@ def _echo_interrupted() -> None:
|
||||
click.echo()
|
||||
|
||||
|
||||
async def _send_turn(state: ReplState, text: str) -> None:
|
||||
"""Send one user turn, attaching whatever its ``@path`` mentions point at."""
|
||||
from plyngent.cli.mentions import resolve_mentions
|
||||
|
||||
payload = resolve_mentions(state, text)
|
||||
for note in payload.notes:
|
||||
click.secho(note, fg="yellow")
|
||||
try:
|
||||
_ = await run_user_text_with_retries(state.agent, payload.text, images=payload.images or None)
|
||||
finally:
|
||||
state.expire_yolo_once()
|
||||
|
||||
|
||||
async def run_repl(state: ReplState) -> None:
|
||||
"""Interactive chat loop with readline editing, history, and Tab completion."""
|
||||
setup_readline(state)
|
||||
@@ -79,16 +92,10 @@ async def run_repl(state: ReplState) -> None:
|
||||
text = state.pending_user_text
|
||||
state.pending_user_text = None
|
||||
_echo_user(text)
|
||||
try:
|
||||
_ = await run_user_text_with_retries(state.agent, text)
|
||||
finally:
|
||||
state.expire_yolo_once()
|
||||
await _send_turn(state, text)
|
||||
continue
|
||||
|
||||
_echo_user(entry)
|
||||
try:
|
||||
_ = await run_user_text_with_retries(state.agent, entry)
|
||||
finally:
|
||||
state.expire_yolo_once()
|
||||
await _send_turn(state, entry)
|
||||
except KeyboardInterrupt:
|
||||
_echo_interrupted()
|
||||
|
||||
@@ -13,10 +13,11 @@ from plyngent.cli.interrupt import install_sigint_router, sigint_cancels
|
||||
from plyngent.cli.limits import reset_auto_continue_turn
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine, Sequence
|
||||
|
||||
from plyngent.agent import AgentEvent
|
||||
from plyngent.agent.chat import ChatAgent
|
||||
from plyngent.lmproto.openai_compatible.model import ChatImagePart
|
||||
|
||||
# Auto-retry budget after the first failure (10 attempts by default).
|
||||
DEFAULT_MAX_AUTO_RETRIES = 10
|
||||
@@ -238,10 +239,11 @@ async def run_user_text_with_retries(
|
||||
agent: ChatAgent,
|
||||
text: str,
|
||||
*,
|
||||
images: Sequence[ChatImagePart] | None = None,
|
||||
delays: tuple[float, ...] = DEFAULT_RETRY_DELAYS_SECONDS,
|
||||
) -> bool:
|
||||
"""Send a new user message with auto-retry (empty ``delays`` = no auto-retry)."""
|
||||
return await run_turn_with_retries(agent, starter=lambda: agent.run(text), delays=delays)
|
||||
return await run_turn_with_retries(agent, starter=lambda: agent.run(text, images=images), delays=delays)
|
||||
|
||||
|
||||
async def retry_pending_with_retries(agent: ChatAgent) -> bool:
|
||||
|
||||
@@ -655,6 +655,7 @@ class ReplState:
|
||||
|
||||
self.config.reload()
|
||||
self.instance_state.workspace.path_denylist = tuple(self.config.agent_config.path_denylist or ())
|
||||
self.instance_state.workspace.trash_enabled = self.config.trash_config.enabled
|
||||
# Roots and their switches came from the file that was just re-read.
|
||||
self._install_skills()
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from plyngent.lmproto.openai_compatible.model import (
|
||||
SystemChatMessage,
|
||||
ToolChatMessage,
|
||||
UserChatMessage,
|
||||
content_to_text,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -225,7 +226,7 @@ def build_transcript(messages: Sequence[AnyChatMessage], *, pending_retry_text:
|
||||
|
||||
if turns and pending_retry_text is not None:
|
||||
user = turns[-1].user.message
|
||||
if isinstance(user, UserChatMessage) and user.content == pending_retry_text:
|
||||
if isinstance(user, UserChatMessage) and content_to_text(user.content) == pending_retry_text:
|
||||
turns[-1] = replace(turns[-1], incomplete=True)
|
||||
return Transcript(prelude=tuple(prelude), turns=tuple(turns))
|
||||
|
||||
@@ -239,7 +240,9 @@ def echo_row(row: Row, *, detail: Detail) -> None:
|
||||
_echo_assistant(row, reasoning=detail == "full")
|
||||
return
|
||||
click.echo(row.prefix())
|
||||
click.echo(row.message.content or "")
|
||||
message = row.message
|
||||
click.echo(content_to_text(message.content) if isinstance(message, UserChatMessage) else message.content)
|
||||
click.echo()
|
||||
click.echo()
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from .models import McpConfig as McpConfig
|
||||
from .models import McpServerConfig as McpServerConfig
|
||||
from .models import ModelConfig as ModelConfig
|
||||
from .models import NetworkingConfig as NetworkingConfig
|
||||
from .models import NotifyConfig as NotifyConfig
|
||||
from .models import OpenAICompatibleProvider as OpenAICompatibleProvider
|
||||
from .models import OpenAIProvider as OpenAIProvider
|
||||
from .models import PluginsConfig as PluginsConfig
|
||||
@@ -24,6 +25,7 @@ from .models import ProviderConfig as ProviderConfig
|
||||
from .models import ReasoningEffortConfig as ReasoningEffortConfig
|
||||
from .models import SkillDiscoverySource as SkillDiscoverySource
|
||||
from .models import SkillsConfig as SkillsConfig
|
||||
from .models import TrashConfig as TrashConfig
|
||||
from .models import compose_agent_system_content as compose_agent_system_content
|
||||
from .path import get_default_path as _get_default_path
|
||||
from .reasoning import ReasoningConfig as ReasoningConfig
|
||||
|
||||
@@ -50,6 +50,8 @@ retrying that path.
|
||||
fix `old_string` or `max_replaces`; shell workarounds rarely help.
|
||||
- `write_file` refuses to replace an existing file (read it, or change it with \
|
||||
`edit_replace` / `edit_lineno`); a full replacement needs `overwrite=true`, which asks the human.
|
||||
- `delete_path` moves the path into the system trash, so a delete stays undoable \
|
||||
(`permanent=true` deletes for good; scratch paths under the OS temp dir always do).
|
||||
- `edit_replace` defaults to the first match; if remaining matches are reported, \
|
||||
raise `max_replaces` or narrow `old_string`.
|
||||
- `edit_lineno` edits only lines you read via `read_file(path, with_lineno=true)`; \
|
||||
@@ -278,6 +280,47 @@ class SkillsConfig(Struct, omit_defaults=True):
|
||||
allow_write: bool = False
|
||||
|
||||
|
||||
class NotifyConfig(Struct, omit_defaults=True):
|
||||
"""Desktop notifications shown while a prompt waits on the human.
|
||||
|
||||
A prompt is announced only after ``grace_seconds`` of waiting, so answering
|
||||
at the terminal stays silent; ``min_interval_seconds`` throttles bursts.
|
||||
``channels`` picks the surfaces: ``auto`` tries the desktop first and falls
|
||||
back to the terminal, ``desktop`` / ``terminal`` pin one of them.
|
||||
|
||||
Desktop notifications come from the optional ``desktop-notifier`` extra
|
||||
(``pip install plyngent[notify]``); without it the terminal channel is all
|
||||
that is left. ``actions = false`` keeps the notification text-only, and
|
||||
``include_reason`` lets the one-line reason through — it can name a command
|
||||
or a URL, so it stays off by default (notifications outlive the prompt and
|
||||
may be mirrored to a lock screen).
|
||||
"""
|
||||
|
||||
enabled: bool = True
|
||||
grace_seconds: float = 5.0
|
||||
min_interval_seconds: float = 10.0
|
||||
channels: list[str] = field(default_factory=lambda: ["auto"])
|
||||
actions: bool = True
|
||||
include_reason: bool = False
|
||||
# Terminal protocol override: "" = detect, else osc777 / osc9 / bell.
|
||||
protocol: str = ""
|
||||
|
||||
|
||||
class TrashConfig(Struct, omit_defaults=True):
|
||||
"""Whether a delete lands in the system trash or is final.
|
||||
|
||||
``enabled`` (the default) sends every path ``delete_path`` removes to the
|
||||
desktop's own trash — the freedesktop trash on Linux, the Finder trash on
|
||||
macOS, the Recycle Bin on Windows — so a deletion can be undone there.
|
||||
``false`` restores the old behaviour of removing the path outright.
|
||||
|
||||
Scratch paths under the OS temporary directory, and a call that passes
|
||||
``permanent=true``, are deleted for good either way.
|
||||
"""
|
||||
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ModelConfig(Struct, omit_defaults=True):
|
||||
"""Capability flags and optional routing overrides for a model.
|
||||
|
||||
@@ -344,9 +387,9 @@ class ProviderConfig(Struct, tag_field="preset", omit_defaults=True):
|
||||
def _default_openai_models() -> dict[str, ModelConfig]:
|
||||
"""Current OpenAI text catalog when TOML omits ``models``."""
|
||||
return {
|
||||
"gpt-5.4": ModelConfig(text=True),
|
||||
"gpt-5.4-mini": ModelConfig(text=True),
|
||||
"gpt-5.4-nano": ModelConfig(text=True),
|
||||
"gpt-5.4": ModelConfig(text=True, image_in=True),
|
||||
"gpt-5.4-mini": ModelConfig(text=True, image_in=True),
|
||||
"gpt-5.4-nano": ModelConfig(text=True, image_in=True),
|
||||
}
|
||||
|
||||
|
||||
@@ -382,9 +425,14 @@ class AnthropicProvider(ProviderConfig, tag="anthropic"):
|
||||
|
||||
|
||||
def _default_deepseek_models() -> dict[str, ModelConfig]:
|
||||
"""Current DeepSeek text catalog when TOML omits ``models``."""
|
||||
"""Current DeepSeek text catalog when TOML omits ``models``.
|
||||
|
||||
Only ``deepseek-flash`` reads pictures (JPEG, PNG, GIF, WebP in a user
|
||||
message); ``deepseek-v4-pro`` stays text-only, so ``image_in`` is the flag
|
||||
that decides whether a turn may carry one.
|
||||
"""
|
||||
return {
|
||||
"deepseek-flash": ModelConfig(text=True),
|
||||
"deepseek-flash": ModelConfig(text=True, image_in=True),
|
||||
"deepseek-v4-pro": ModelConfig(text=True),
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,11 @@ from .models import (
|
||||
McpConfig,
|
||||
ModelConfig,
|
||||
NetworkingConfig,
|
||||
NotifyConfig,
|
||||
PluginsConfig,
|
||||
Provider,
|
||||
SkillsConfig,
|
||||
TrashConfig,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -66,6 +68,38 @@ def _parse_skills(raw: dict[str, object]) -> SkillsConfig:
|
||||
return SkillsConfig()
|
||||
|
||||
|
||||
def _parse_notify(raw: dict[str, object]) -> NotifyConfig:
|
||||
"""Parse the ``[notify]`` section, falling back to defaults.
|
||||
|
||||
Unknown keys are rejected like ``[mcp]`` does: a typo degrades to the
|
||||
defaults (notifications on, ``auto`` channel) instead of half-applying.
|
||||
"""
|
||||
try:
|
||||
config = msgspec.convert(raw, NotifyConfig)
|
||||
except msgspec.ValidationError:
|
||||
return NotifyConfig()
|
||||
known = set(NotifyConfig.__struct_fields__)
|
||||
if set(raw) - known:
|
||||
return NotifyConfig()
|
||||
return config
|
||||
|
||||
|
||||
def _parse_trash(raw: dict[str, object]) -> TrashConfig:
|
||||
"""Parse the ``[trash]`` section, falling back to defaults.
|
||||
|
||||
Unknown keys are rejected like ``[mcp]`` does: a typo leaves deletes going
|
||||
to the trash instead of half-applying a policy about destructive ones.
|
||||
"""
|
||||
try:
|
||||
config = msgspec.convert(raw, TrashConfig)
|
||||
except msgspec.ValidationError:
|
||||
return TrashConfig()
|
||||
known = set(TrashConfig.__struct_fields__)
|
||||
if set(raw) - known:
|
||||
return TrashConfig()
|
||||
return config
|
||||
|
||||
|
||||
def _parse_mcp(raw: dict[str, object]) -> McpConfig:
|
||||
"""Parse the ``[mcp]`` section, falling back to defaults.
|
||||
|
||||
@@ -160,6 +194,8 @@ class ConfigStore:
|
||||
_networking: NetworkingConfig
|
||||
_mcp: McpConfig
|
||||
_skills: SkillsConfig
|
||||
_notify: NotifyConfig
|
||||
_trash: TrashConfig
|
||||
_providers: dict[str, Provider]
|
||||
_bad_providers: dict[str, object]
|
||||
_recoverable_providers: dict[str, Provider]
|
||||
@@ -174,6 +210,8 @@ class ConfigStore:
|
||||
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {})))
|
||||
self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {})))
|
||||
self._skills = _parse_skills(cast("dict[str, object]", raw.get("skills", {})))
|
||||
self._notify = _parse_notify(cast("dict[str, object]", raw.get("notify", {})))
|
||||
self._trash = _parse_trash(cast("dict[str, object]", raw.get("trash", {})))
|
||||
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document)
|
||||
|
||||
@property
|
||||
@@ -238,6 +276,20 @@ class ConfigStore:
|
||||
"""Typed skills section (roots, discovery sources, catalog limits)."""
|
||||
return self._skills
|
||||
|
||||
# -- notify (read-only) --
|
||||
|
||||
@property
|
||||
def notify_config(self) -> NotifyConfig:
|
||||
"""Typed notifications section (grace, channels, actions)."""
|
||||
return self._notify
|
||||
|
||||
# -- trash (read-only) --
|
||||
|
||||
@property
|
||||
def trash_config(self) -> TrashConfig:
|
||||
"""Typed trash section (whether a delete can be undone)."""
|
||||
return self._trash
|
||||
|
||||
def set_plugins_enable(self, names: Sequence[str]) -> PluginsConfig:
|
||||
"""Replace the plugin enable list (in-memory; call :meth:`write` to persist)."""
|
||||
cleaned = [name.strip() for name in names if name.strip()]
|
||||
@@ -442,6 +494,9 @@ class ConfigStore:
|
||||
self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {})))
|
||||
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {})))
|
||||
self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {})))
|
||||
self._skills = _parse_skills(cast("dict[str, object]", raw.get("skills", {})))
|
||||
self._notify = _parse_notify(cast("dict[str, object]", raw.get("notify", {})))
|
||||
self._trash = _parse_trash(cast("dict[str, object]", raw.get("trash", {})))
|
||||
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(self._document)
|
||||
|
||||
# -- internal sync helpers --
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, Literal, cast, overload
|
||||
import msgspec
|
||||
|
||||
from plyngent.lmproto.openai_compatible.client import BaseOpenAIClient
|
||||
from plyngent.lmproto.openai_compatible.compat import chat_messages_with_images
|
||||
from plyngent.lmproto.openai_compatible.config import OpenAIConfig # noqa: TC001
|
||||
from plyngent.lmproto.openai_compatible.model import ( # noqa: TC001
|
||||
ChatCompletionChunk,
|
||||
@@ -49,6 +50,11 @@ class OpenAIClient(BaseOpenAIClient):
|
||||
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||
param = msgspec.structs.replace(param, stream=stream)
|
||||
# Lift images into content parts, like every other chat-completions wire;
|
||||
# the OpenAI API takes the ``developer`` role as-is, so only that step.
|
||||
images = chat_messages_with_images(param.messages)
|
||||
if images != param.messages:
|
||||
param = msgspec.structs.replace(param, messages=images)
|
||||
data = self.encoder.encode(param)
|
||||
if stream:
|
||||
resp = await self.session.post(
|
||||
|
||||
@@ -98,10 +98,15 @@ class ResponseFunctionToolCall(Struct, tag_field="type", tag="function_call"):
|
||||
|
||||
|
||||
class ResponseFunctionToolCallOutput(Struct, tag_field="type", tag="function_call_output"):
|
||||
"""Tool result item passed back in subsequent ``input``."""
|
||||
"""Tool result item passed back in subsequent ``input``.
|
||||
|
||||
``output`` may be a part array instead of text: a tool that returns an image
|
||||
puts an ``input_image`` part beside its ``input_text`` (the Responses guide
|
||||
documents images there, and a live probe reads them back).
|
||||
"""
|
||||
|
||||
call_id: str
|
||||
output: str
|
||||
output: str | list[dict[str, Any]]
|
||||
id: str | Unset = UNSET
|
||||
status: ResponseItemStatus | Unset = UNSET
|
||||
|
||||
|
||||
@@ -7,12 +7,18 @@ from typing import TYPE_CHECKING, Any, cast
|
||||
import msgspec
|
||||
from msgspec import UNSET
|
||||
|
||||
from .model import AssistantChatMessage, DeveloperChatMessage, SystemChatMessage
|
||||
from .model import (
|
||||
AssistantChatMessage,
|
||||
DeveloperChatMessage,
|
||||
SystemChatMessage,
|
||||
ToolChatMessage,
|
||||
UserChatMessage,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from .model import AnyChatMessage, ChatCompletionsParam
|
||||
from .model import AnyChatMessage, ChatCompletionsParam, ChatImagePart
|
||||
|
||||
|
||||
def developer_to_system_message(message: DeveloperChatMessage) -> SystemChatMessage:
|
||||
@@ -38,6 +44,38 @@ def coerce_developer_messages_to_system(messages: Sequence[AnyChatMessage]) -> l
|
||||
return out if changed else list(messages)
|
||||
|
||||
|
||||
def chat_messages_with_images(messages: Sequence[AnyChatMessage]) -> list[AnyChatMessage]:
|
||||
"""Return *messages* with tool-result images lifted into the chat wire.
|
||||
|
||||
A user turn already carries its images in ``content`` blocks, which chat
|
||||
completions take as-is. A ``tool`` role message may only carry text, so a
|
||||
tool result's images move into the user turn emitted right after the run of
|
||||
consecutive tool results.
|
||||
|
||||
With no such images the sequence's items come back unchanged.
|
||||
"""
|
||||
out: list[AnyChatMessage] = []
|
||||
pending: list[ChatImagePart] = []
|
||||
changed = False
|
||||
|
||||
def flush_images() -> None:
|
||||
if pending:
|
||||
out.append(UserChatMessage(content=list(pending)))
|
||||
pending.clear()
|
||||
|
||||
for message in messages:
|
||||
if isinstance(message, ToolChatMessage) and message.images is not UNSET and message.images:
|
||||
out.append(msgspec.structs.replace(message, images=UNSET))
|
||||
pending.extend(message.images)
|
||||
changed = True
|
||||
continue
|
||||
if pending and not isinstance(message, ToolChatMessage):
|
||||
flush_images()
|
||||
out.append(message)
|
||||
flush_images()
|
||||
return out if changed else list(messages)
|
||||
|
||||
|
||||
def coerce_chat_messages(messages: Sequence[AnyChatMessage]) -> list[AnyChatMessage]:
|
||||
"""Return *messages* as a chat-completions wire can take them.
|
||||
|
||||
@@ -46,18 +84,19 @@ def coerce_chat_messages(messages: Sequence[AnyChatMessage]) -> list[AnyChatMess
|
||||
and ``AssistantChatMessage.provider_blocks`` is dropped — those blocks
|
||||
belong to the surface that produced them (Anthropic ``thinking`` and
|
||||
server-tool blocks; see ``agent.messages_bridge``) and no other provider
|
||||
knows the key.
|
||||
knows the key. Tool-result images are lifted last (see
|
||||
:func:`chat_messages_with_images`).
|
||||
"""
|
||||
coerced = coerce_developer_messages_to_system(messages)
|
||||
changed = False
|
||||
out: list[AnyChatMessage] = []
|
||||
stripped: list[AnyChatMessage] = []
|
||||
for message in coerced:
|
||||
if isinstance(message, AssistantChatMessage) and message.provider_blocks is not UNSET:
|
||||
out.append(msgspec.structs.replace(message, provider_blocks=UNSET))
|
||||
stripped.append(msgspec.structs.replace(message, provider_blocks=UNSET))
|
||||
changed = True
|
||||
else:
|
||||
out.append(message)
|
||||
return out if changed else coerced
|
||||
stripped.append(message)
|
||||
return chat_messages_with_images(stripped if changed else coerced)
|
||||
|
||||
|
||||
def coerce_chat_completions_param(param: ChatCompletionsParam) -> ChatCompletionsParam:
|
||||
|
||||
@@ -19,6 +19,41 @@ type Verbosity = Literal["low", "medium", "high"]
|
||||
type GrammarSyntax = Literal["lark", "regex"]
|
||||
|
||||
|
||||
type ImageDetail = Literal["low", "high", "original", "auto"]
|
||||
|
||||
|
||||
class ChatImageUrl(Struct, omit_defaults=True):
|
||||
"""The ``image_url`` payload of one image part (the canonical chat shape)."""
|
||||
|
||||
url: str
|
||||
detail: ImageDetail | Unset = UNSET
|
||||
|
||||
|
||||
class ChatImagePart(Struct, tag_field="type", tag="image_url"):
|
||||
"""One image a message carries: an ``http(s)`` URL or a ``data:`` URL.
|
||||
|
||||
This is the shape OpenAI-compatible chat completions take. The other two
|
||||
surfaces lift it into their own form — ``compat.chat_messages_with_images``
|
||||
for chat completions, ``agent.messages_bridge`` (Anthropic ``image``
|
||||
blocks) and ``agent.responses_bridge`` (``input_image`` parts).
|
||||
"""
|
||||
|
||||
image_url: ChatImageUrl
|
||||
|
||||
|
||||
class ChatTextPart(Struct, tag_field="type", tag="text"):
|
||||
"""A text block beside images, when a message's content is a part array."""
|
||||
|
||||
text: str
|
||||
|
||||
|
||||
type ChatContentPart = ChatTextPart | ChatImagePart
|
||||
type ChatContent = str | list[ChatContentPart]
|
||||
|
||||
# What a text view of a part array shows where an image sits.
|
||||
IMAGE_MARKER = "[image]"
|
||||
|
||||
|
||||
class ChatMessage(Struct):
|
||||
content: str
|
||||
|
||||
@@ -28,6 +63,12 @@ class SystemChatMessage(ChatMessage, tag_field="role", tag="system"):
|
||||
|
||||
|
||||
class UserChatMessage(ChatMessage, tag_field="role", tag="user"):
|
||||
# A user turn may carry images beside its text; chat completions already take
|
||||
# that shape as ``content`` blocks, and the other two surfaces translate it
|
||||
# (``agent.messages_bridge``, ``agent.responses_bridge``). The wider type is
|
||||
# deliberate: only a user turn takes images, so the base stays a string and
|
||||
# prose-only callers read it through ``content_to_text``.
|
||||
content: str | list[ChatContentPart] # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
name: str | Unset = UNSET
|
||||
|
||||
|
||||
@@ -35,6 +76,17 @@ class DeveloperChatMessage(ChatMessage, tag_field="role", tag="developer"):
|
||||
name: str | Unset = UNSET
|
||||
|
||||
|
||||
def content_to_text(content: ChatContent) -> str:
|
||||
"""The plain text of a message ``content``, with ``[image]`` for each image.
|
||||
|
||||
Callers that only need prose (summaries, transcripts, exports, comparisons)
|
||||
must not print a part array's ``repr``.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
return " ".join(part.text if isinstance(part, ChatTextPart) else IMAGE_MARKER for part in content)
|
||||
|
||||
|
||||
type NamedChatMessage = SystemChatMessage | UserChatMessage | DeveloperChatMessage
|
||||
|
||||
|
||||
@@ -84,6 +136,11 @@ class AssistantChatMessage(Struct, tag_field="role", tag="assistant"):
|
||||
|
||||
class ToolChatMessage(ChatMessage, tag_field="role", tag="tool"):
|
||||
tool_call_id: str
|
||||
# Images the tool returned. Chat completions cannot put an image in a
|
||||
# ``tool`` message, so on that wire they move into the user message that
|
||||
# follows the tool run (``compat.chat_messages_with_images``); the Anthropic
|
||||
# and Responses surfaces carry them in the result itself.
|
||||
images: list[ChatImagePart] | Unset = UNSET
|
||||
|
||||
|
||||
type AnyChatMessage = (
|
||||
|
||||
+183
-42
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import contextvars
|
||||
import select
|
||||
import sys
|
||||
import threading
|
||||
@@ -117,6 +118,78 @@ def _break_output() -> None:
|
||||
hook()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PromptWait:
|
||||
"""A prompt that is about to block on the human.
|
||||
|
||||
``kind`` names the prompt family (``confirm``, ``ask``, ``secret``,
|
||||
``choose``, ``policy``, ``access``, ``skills``, ``limit``) so the host can
|
||||
tailor what it shows and which answers it offers; ``title`` is short and
|
||||
safe to surface anywhere, while ``detail`` carries one optional line the
|
||||
caller has already redacted.
|
||||
"""
|
||||
|
||||
kind: str
|
||||
title: str
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PromptAnswerSource(Protocol):
|
||||
"""Answers a waiting prompt may take from a host surface (a notification)."""
|
||||
|
||||
@property
|
||||
def supports_actions(self) -> bool:
|
||||
"""Whether :meth:`take` can answer this prompt at all."""
|
||||
...
|
||||
|
||||
def take(self) -> str | None:
|
||||
"""Take the next host answer without blocking (``allow`` / ``deny`` / reply text)."""
|
||||
...
|
||||
|
||||
|
||||
type PromptWaitHook = Callable[[PromptWait], AbstractContextManager[PromptAnswerSource | None]]
|
||||
|
||||
# Hook run *while* a prompt blocks on the human. It yields the answer source the
|
||||
# read consults (``None`` = notify only), so a host can reach someone who has
|
||||
# switched to another window; without a hook, prompting behaves as before.
|
||||
_wait_hook: PromptWaitHook | None = None
|
||||
|
||||
# Depth of the prompt_wait blocks currently entered (see prompt_wait).
|
||||
_wait_depth: contextvars.ContextVar[int] = contextvars.ContextVar("prompt_wait_depth", default=0)
|
||||
|
||||
|
||||
def set_prompt_wait_hook(hook: PromptWaitHook | None) -> None:
|
||||
"""Install the hook run while a prompt blocks (None clears it)."""
|
||||
global _wait_hook # noqa: PLW0603
|
||||
_wait_hook = hook
|
||||
|
||||
|
||||
def get_prompt_wait_hook() -> PromptWaitHook | None:
|
||||
"""The installed prompt-wait hook, if any."""
|
||||
return _wait_hook
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def prompt_wait(kind: str, title: str, detail: str = "") -> Generator[PromptAnswerSource | None]:
|
||||
"""Announce a blocking prompt to the host; yields its answer source (or None).
|
||||
|
||||
Only the outermost prompt of a nested read is announced: a confirm that asks
|
||||
a follow-up question while it is already on screen must not notify — and
|
||||
interrupt — a second time.
|
||||
"""
|
||||
hook = _wait_hook
|
||||
if hook is None or _wait_depth.get() > 0:
|
||||
yield None
|
||||
return
|
||||
token = _wait_depth.set(_wait_depth.get() + 1)
|
||||
try:
|
||||
with hook(PromptWait(kind, title, detail)) as source:
|
||||
yield source
|
||||
finally:
|
||||
_wait_depth.reset(token)
|
||||
|
||||
|
||||
def _readline_input(prompt: str, *, completions: Sequence[str] | None = None) -> str:
|
||||
"""``input()`` with optional Tab completion via readline when available."""
|
||||
try:
|
||||
@@ -157,45 +230,60 @@ type PollFn = Callable[
|
||||
|
||||
def _read_line_with_timeout_posix(
|
||||
prompt: str,
|
||||
timeout: float,
|
||||
timeout: float | None,
|
||||
*,
|
||||
stream: TextIO,
|
||||
poll: PollFn,
|
||||
source: PromptAnswerSource | None = None,
|
||||
) -> str | None:
|
||||
"""Blocking line read on POSIX: poll stdin, then read one line on data.
|
||||
|
||||
Returns the line (without trailing newline), ``""`` for an empty Enter, or
|
||||
``None`` when the timeout elapses before any line is complete (EOF too).
|
||||
Raises :class:`PromptCancelledError` when :func:`prompt_cancelled` turns true
|
||||
while waiting (the poll loop notices within one interval).
|
||||
``None`` when the timeout elapses before any line is complete (EOF too); a
|
||||
``None`` *timeout* waits until a line or a *source* answer arrives. Raises
|
||||
:class:`PromptCancelledError` when :func:`prompt_cancelled` turns true while
|
||||
waiting (the poll loop notices within one interval).
|
||||
"""
|
||||
_ = sys.stdout.write(prompt)
|
||||
_ = sys.stdout.flush()
|
||||
deadline = time.monotonic() + timeout
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
if deadline is None:
|
||||
remaining = 0.1
|
||||
else:
|
||||
remaining = min(deadline - time.monotonic(), 0.1)
|
||||
if remaining <= 0:
|
||||
return None
|
||||
if prompt_cancelled():
|
||||
raise PromptCancelledError
|
||||
ready, _, _ = poll([stream], [], [], min(remaining, 0.1))
|
||||
if not ready:
|
||||
continue
|
||||
line = stream.readline()
|
||||
if line == "":
|
||||
return None # EOF
|
||||
return line.rstrip("\n")
|
||||
ready, _, _ = poll([stream], [], [], remaining)
|
||||
if ready:
|
||||
line = stream.readline()
|
||||
if line == "":
|
||||
return None # EOF
|
||||
return line.rstrip("\n")
|
||||
if source is not None:
|
||||
answer = source.take()
|
||||
if answer is not None:
|
||||
_ = sys.stdout.write(f"{answer}\n")
|
||||
_ = sys.stdout.flush()
|
||||
return answer
|
||||
|
||||
|
||||
def _read_line_with_timeout_windows(prompt: str, timeout: float) -> str | None:
|
||||
def _read_line_with_timeout_windows(
|
||||
prompt: str,
|
||||
timeout: float | None,
|
||||
*,
|
||||
source: PromptAnswerSource | None = None,
|
||||
) -> str | None:
|
||||
"""Blocking line read on Windows via msvcrt char polling (best-effort)."""
|
||||
import msvcrt
|
||||
|
||||
_ = sys.stdout.write(prompt)
|
||||
_ = sys.stdout.flush()
|
||||
deadline = time.monotonic() + timeout
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
buf: list[str] = []
|
||||
while time.monotonic() < deadline:
|
||||
while deadline is None or time.monotonic() < deadline:
|
||||
if prompt_cancelled():
|
||||
raise PromptCancelledError
|
||||
if msvcrt.kbhit():
|
||||
@@ -214,28 +302,47 @@ def _read_line_with_timeout_windows(prompt: str, timeout: float) -> str | None:
|
||||
buf.append(ch)
|
||||
_ = sys.stdout.write(ch)
|
||||
_ = sys.stdout.flush()
|
||||
else:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
if source is not None:
|
||||
answer = source.take()
|
||||
if answer is not None:
|
||||
_ = sys.stdout.write(f"{answer}\n")
|
||||
_ = sys.stdout.flush()
|
||||
return answer
|
||||
time.sleep(0.05)
|
||||
_ = sys.stdout.write("\n")
|
||||
_ = sys.stdout.flush()
|
||||
return None
|
||||
|
||||
|
||||
def read_line_with_timeout(prompt: str, timeout: float) -> str | None:
|
||||
"""Read one line, blocking at most ``timeout`` seconds.
|
||||
def read_line_with_timeout(
|
||||
prompt: str,
|
||||
timeout: float | None,
|
||||
*,
|
||||
source: PromptAnswerSource | None = None,
|
||||
) -> str | None:
|
||||
"""Read one line, blocking at most ``timeout`` seconds (``None`` = no limit).
|
||||
|
||||
Returns the typed line (without trailing newline), ``""`` for an empty
|
||||
Enter, or ``None`` when the timeout elapses first (EOF too). Ctrl+C
|
||||
propagates as :class:`KeyboardInterrupt`; :func:`cancel_prompt_read` (the
|
||||
SIGINT target of an off-loop prompt read) raises
|
||||
:class:`PromptCancelledError` instead. POSIX polls stdin via ``select``;
|
||||
Windows uses ``msvcrt`` char polling (best-effort).
|
||||
Enter, or ``None`` when the timeout elapses first (EOF too). With a
|
||||
*source*, its answers end the read as well — the answer is echoed on the
|
||||
prompt line like a typed one. Ctrl+C propagates as
|
||||
:class:`KeyboardInterrupt`; :func:`cancel_prompt_read` (the SIGINT target of
|
||||
an off-loop prompt read) raises :class:`PromptCancelledError` instead.
|
||||
POSIX polls stdin via ``select``; Windows uses ``msvcrt`` char polling
|
||||
(best-effort).
|
||||
"""
|
||||
if timeout < 0:
|
||||
if timeout is not None and timeout < 0:
|
||||
timeout = 0.0
|
||||
if sys.platform == "win32":
|
||||
return _read_line_with_timeout_windows(prompt, timeout)
|
||||
return _read_line_with_timeout_posix(prompt, timeout, stream=sys.stdin, poll=cast("PollFn", select.select))
|
||||
return _read_line_with_timeout_windows(prompt, timeout, source=source)
|
||||
return _read_line_with_timeout_posix(
|
||||
prompt,
|
||||
timeout,
|
||||
stream=sys.stdin,
|
||||
poll=cast("PollFn", select.select),
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
class ClickPromptBackend:
|
||||
@@ -345,11 +452,12 @@ def get_prompt_backend() -> PromptBackend:
|
||||
|
||||
|
||||
def reset_prompting() -> None:
|
||||
"""Restore the default backend; clear the pause/output hooks and cancel flag (tests)."""
|
||||
global _backend, _pause_factory, _before_output # noqa: PLW0603
|
||||
"""Restore the default backend; clear the pause/output/wait hooks and cancel flag (tests)."""
|
||||
global _backend, _pause_factory, _before_output, _wait_hook # noqa: PLW0603
|
||||
_backend = ClickPromptBackend()
|
||||
_pause_factory = None
|
||||
_before_output = None
|
||||
_wait_hook = None
|
||||
_prompt_cancel.clear()
|
||||
|
||||
|
||||
@@ -401,6 +509,34 @@ def _show_choices(
|
||||
backend.echo(" (or type a custom answer)")
|
||||
|
||||
|
||||
def _read_with_source(
|
||||
prompt: str,
|
||||
source: PromptAnswerSource | None,
|
||||
*,
|
||||
default: str | None,
|
||||
completions: Sequence[str] | None = None,
|
||||
) -> str:
|
||||
"""Read one answer line, also accepting one offered by *source*.
|
||||
|
||||
The backend's readline path is kept whenever nothing can answer remotely, so
|
||||
line editing (and Tab completion) only go away when the human asked for a
|
||||
notification that carries the answer.
|
||||
"""
|
||||
backend = get_prompt_backend()
|
||||
if source is None:
|
||||
return backend.read_line(prompt, default=default, completions=completions).strip()
|
||||
try:
|
||||
answer = source.take() if not sys.stdin.isatty() else read_line_with_timeout(f"{prompt}: ", None, source=source)
|
||||
except OSError, ValueError, AttributeError:
|
||||
answer = source.take()
|
||||
if answer is None:
|
||||
if default is not None:
|
||||
return default
|
||||
msg = "prompt cancelled"
|
||||
raise NonInteractiveError(msg)
|
||||
return answer.strip()
|
||||
|
||||
|
||||
def ask(
|
||||
prompt: str,
|
||||
*,
|
||||
@@ -415,8 +551,10 @@ def ask(
|
||||
if not backend.is_interactive() and default is None:
|
||||
msg = f"non-interactive: cannot prompt for {prompt!r}"
|
||||
raise NonInteractiveError(msg)
|
||||
backend.secho(prompt, fg="yellow")
|
||||
return backend.read_line("Answer", default=default, completions=completions).strip()
|
||||
with prompt_wait("ask", prompt) as wait:
|
||||
backend.secho(prompt, fg="yellow")
|
||||
source = wait if wait is not None and wait.supports_actions else None
|
||||
return _read_with_source("Answer", source, default=default, completions=completions)
|
||||
|
||||
|
||||
def ask_secret(prompt: str) -> str:
|
||||
@@ -428,8 +566,9 @@ def ask_secret(prompt: str) -> str:
|
||||
if not backend.is_interactive():
|
||||
msg = f"non-interactive: cannot prompt for secret {prompt!r}"
|
||||
raise NonInteractiveError(msg)
|
||||
backend.secho(prompt, fg="yellow")
|
||||
return backend.read_secret_line("Secret")
|
||||
with prompt_wait("secret", prompt):
|
||||
backend.secho(prompt, fg="yellow")
|
||||
return backend.read_secret_line("Secret")
|
||||
|
||||
|
||||
def choose(
|
||||
@@ -461,11 +600,12 @@ def choose(
|
||||
raise NonInteractiveError(msg)
|
||||
|
||||
while True:
|
||||
raw = backend.read_line(
|
||||
"Choice",
|
||||
default=default_display,
|
||||
completions=completions,
|
||||
).strip()
|
||||
with prompt_wait("choose", prompt):
|
||||
raw = backend.read_line(
|
||||
"Choice",
|
||||
default=default_display,
|
||||
completions=completions,
|
||||
).strip()
|
||||
if not raw:
|
||||
if default is not None:
|
||||
return default
|
||||
@@ -489,7 +629,8 @@ def confirm(prompt: str, *, default: bool = False) -> bool:
|
||||
if not backend.is_interactive():
|
||||
return default
|
||||
backend.echo()
|
||||
return backend.confirm(prompt, default=default)
|
||||
with prompt_wait("confirm", prompt):
|
||||
return backend.confirm(prompt, default=default)
|
||||
|
||||
|
||||
def form(
|
||||
|
||||
@@ -8,6 +8,7 @@ the process: start, tool listing, tool calls, reconnect, shutdown.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
@@ -17,6 +18,10 @@ from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from plyngent.agent.images import image_part_from_bytes
|
||||
from plyngent.agent.tools import ToolOutput
|
||||
from plyngent.lmproto.openai_compatible.model import ChatImagePart
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
@@ -84,9 +89,10 @@ class McpTool:
|
||||
)
|
||||
|
||||
|
||||
def _format_content(result: Json) -> str:
|
||||
"""Flatten a ``tools/call`` result payload into model-facing text."""
|
||||
def _format_content(result: Json) -> ToolOutput:
|
||||
"""Flatten a ``tools/call`` result payload into text plus any images."""
|
||||
parts: list[str] = []
|
||||
images: list[ChatImagePart] = []
|
||||
for item in _json_list(result.get("content")):
|
||||
entry = _json_dict(item)
|
||||
kind = str(entry.get("type", ""))
|
||||
@@ -94,12 +100,24 @@ def _format_content(result: Json) -> str:
|
||||
text = entry.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
elif kind == "image":
|
||||
# MCP carries an image as base64 + mimeType; the model wants the
|
||||
# picture itself, not a ``[image: image/png]`` placeholder.
|
||||
data = entry.get("data")
|
||||
mime = entry.get("mimeType", "?")
|
||||
part = image_part_from_bytes(base64.b64decode(data)) if isinstance(data, str) and data else None
|
||||
if isinstance(part, ChatImagePart):
|
||||
images.append(part)
|
||||
elif isinstance(part, str):
|
||||
parts.append(f"[image: {mime}] {part.removeprefix('error: ')}")
|
||||
else:
|
||||
parts.append(f"[image: {mime}]")
|
||||
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)
|
||||
return ToolOutput(text="\n".join(parts), images=images)
|
||||
|
||||
|
||||
class McpServerConnection:
|
||||
@@ -307,17 +325,17 @@ class McpServerConnection:
|
||||
cursor = next_cursor
|
||||
return tools
|
||||
|
||||
async def call_tool(self, name: str, arguments: Json) -> str:
|
||||
"""Run ``tools/call`` and return the flattened text result.
|
||||
async def call_tool(self, name: str, arguments: Json) -> ToolOutput:
|
||||
"""Run ``tools/call`` and return its text plus any images it produced.
|
||||
|
||||
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)
|
||||
output = _format_content(result)
|
||||
if result.get("isError") is True:
|
||||
return f"error: {text or 'tool reported failure without content'}"
|
||||
return text
|
||||
return ToolOutput(text=f"error: {output.text or 'tool reported failure without content'}")
|
||||
return output
|
||||
|
||||
|
||||
class McpManager:
|
||||
@@ -393,7 +411,7 @@ class McpManager:
|
||||
connection.tools = await connection.list_tools()
|
||||
connection.tools_stale = False
|
||||
|
||||
async def call(self, server: str, tool: str, arguments: Json) -> str:
|
||||
async def call(self, server: str, tool: str, arguments: Json) -> ToolOutput:
|
||||
connection = self._connections.get(server)
|
||||
if connection is None or not connection.connected:
|
||||
msg = f"server {server!r} is not connected"
|
||||
|
||||
@@ -101,6 +101,7 @@ from .workspace import denylist_match as denylist_match
|
||||
from .workspace import get_command_denylist as get_command_denylist
|
||||
from .workspace import get_path_denylist as get_path_denylist
|
||||
from .workspace import get_policy_confirm_timeout as get_policy_confirm_timeout
|
||||
from .workspace import get_trash_enabled as get_trash_enabled
|
||||
from .workspace import get_workspace_root as get_workspace_root
|
||||
from .workspace import grant_policy_command as grant_policy_command
|
||||
from .workspace import granted_access_mode as granted_access_mode
|
||||
@@ -116,5 +117,6 @@ from .workspace import set_path_denylist as set_path_denylist
|
||||
from .workspace import set_policy_confirm_hook as set_policy_confirm_hook
|
||||
from .workspace import set_policy_confirm_timeout as set_policy_confirm_timeout
|
||||
from .workspace import set_static_read_roots as set_static_read_roots
|
||||
from .workspace import set_trash_enabled as set_trash_enabled
|
||||
from .workspace import set_workspace_root as set_workspace_root
|
||||
from .workspace import within_workspace_roots as within_workspace_roots
|
||||
|
||||
@@ -16,7 +16,16 @@ from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
__all__ = ["CommandString", "UnwrappedCommand", "basename", "is_interpreter", "unwrap_command"]
|
||||
__all__ = [
|
||||
"ESCALATION_PROGRAMS",
|
||||
"CommandString",
|
||||
"UnwrappedCommand",
|
||||
"basename",
|
||||
"dash_c_code",
|
||||
"escalators_in_chain",
|
||||
"is_interpreter",
|
||||
"unwrap_command",
|
||||
]
|
||||
|
||||
# Wrappers that run the program named on their own command line.
|
||||
_ENV_WRAPPERS: frozenset[str] = frozenset(
|
||||
@@ -99,6 +108,10 @@ _INTERPRETERS: frozenset[str] = frozenset(
|
||||
# Trailing version of an interpreter: ``python3.12`` / ``node20`` / ``php8.2``.
|
||||
_VERSION_SUFFIX = re.compile(r"[0-9][0-9.]*$")
|
||||
|
||||
# Privilege escalation: these run with rights the session itself does not have,
|
||||
# so a call that reaches one is never approved on the caller's behalf
|
||||
# (``danger._command_reason`` keeps the confirm whatever YOLO mode says).
|
||||
ESCALATION_PROGRAMS: frozenset[str] = frozenset({"doas", "pkexec", "run0", "su", "sudo", "systemd-run"})
|
||||
# ``FOO=bar`` in front of a program (env/sudo style).
|
||||
_ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=")
|
||||
|
||||
@@ -245,6 +258,31 @@ def is_interpreter(base: str) -> bool:
|
||||
return base in _INTERPRETERS or _VERSION_SUFFIX.sub("", base) in _INTERPRETERS
|
||||
|
||||
|
||||
def dash_c_code(command: Sequence[str]) -> tuple[int, str] | None:
|
||||
"""Index and text of the ``-c`` code in an interpreter argv, if present.
|
||||
|
||||
Only interpreters hand ``-c`` a block of code; for anything else it is a
|
||||
plain flag (``grep -c``, ``od -c``) with nothing to review.
|
||||
"""
|
||||
for index, part in enumerate(command[1:], start=1):
|
||||
if part == "-c":
|
||||
return index, command[index + 1] if index + 1 < len(command) else ""
|
||||
# Combined short forms are uncommon; only exact -c is supported.
|
||||
return None
|
||||
|
||||
|
||||
def escalators_in_chain(argv: Sequence[str]) -> tuple[str, ...]:
|
||||
"""Escalation programs *argv* runs: the program itself or a wrapper around it.
|
||||
|
||||
``sudo -u root python …`` and ``doas bash`` both count, whatever program they
|
||||
finally run. Code an interpreter only carries as text is out of scope here —
|
||||
this reads the command line, not what the text would do.
|
||||
"""
|
||||
scan = unwrap_command(argv)
|
||||
names = (scan.base, *scan.wrappers)
|
||||
return tuple(dict.fromkeys(name for name in names if name in ESCALATION_PROGRAMS))
|
||||
|
||||
|
||||
def unwrap_command(argv: Sequence[str]) -> UnwrappedCommand:
|
||||
"""Resolve *argv* to the program behind any wrappers and ``run`` launchers.
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import json
|
||||
import shlex
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from plyngent.tools.command_scan import is_interpreter, unwrap_command
|
||||
from plyngent.tools.workspace import AccessMode, WorkspaceError, resolve_path
|
||||
from plyngent.agent.tools import DangerReason
|
||||
from plyngent.tools.command_scan import dash_c_code, escalators_in_chain, is_interpreter, unwrap_command
|
||||
from plyngent.tools.trash import under_system_temp
|
||||
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_trash_enabled, resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping, Sequence
|
||||
@@ -23,19 +25,6 @@ def _as_argv(args: Mapping[str, object], *, key: str = "command") -> list[str] |
|
||||
return out
|
||||
|
||||
|
||||
def _dash_c_code(command: Sequence[str]) -> tuple[int, str] | None:
|
||||
"""Index and text of the ``-c`` code in an interpreter argv, if present.
|
||||
|
||||
Only interpreters hand ``-c`` a block of code; for anything else it is a
|
||||
plain flag (``grep -c``, ``od -c``) with nothing to review.
|
||||
"""
|
||||
for index, part in enumerate(command[1:], start=1):
|
||||
if part == "-c":
|
||||
return index, command[index + 1] if index + 1 < len(command) else ""
|
||||
# Combined short forms are uncommon; only exact -c is supported.
|
||||
return None
|
||||
|
||||
|
||||
def _indent_block(text: str, *, prefix: str = " ") -> str:
|
||||
"""Indent every line of *text* (for multi-line -c code in confirm prompts)."""
|
||||
if not text:
|
||||
@@ -51,7 +40,7 @@ def _format_argv_for_confirm(argv: Sequence[str], *, code_index: int | None) ->
|
||||
return shlex.join(parts)
|
||||
|
||||
|
||||
def _command_reason(argv: Sequence[str], *, via: str) -> str | None:
|
||||
def _command_reason(argv: Sequence[str], *, via: str) -> DangerReason | None:
|
||||
"""Confirm a command that runs code or detaches, whatever wraps it.
|
||||
|
||||
Multi-line reason (shown inside the CLI confirm box). ``via`` is a short
|
||||
@@ -65,6 +54,11 @@ def _command_reason(argv: Sequence[str], *, via: str) -> str | None:
|
||||
(``env -S "…"``, ``su -c "…"``) hides code the same way, so it is confirmed
|
||||
too, with the text it carries printed under its own ``<wrapper> <option>:``
|
||||
label.
|
||||
|
||||
A command that escalates privileges (``sudo``/``doas``/``pkexec`` as the
|
||||
program or a wrapper) is confirmed with ``yolo_skip=False``. It never adds a
|
||||
reason of its own: the denylist gate in the handler always asks about the
|
||||
escalation, so a second prompt here would only repeat it.
|
||||
"""
|
||||
scan = unwrap_command(argv)
|
||||
interpreter = is_interpreter(scan.base)
|
||||
@@ -75,7 +69,10 @@ def _command_reason(argv: Sequence[str], *, via: str) -> str | None:
|
||||
findings = list(dict.fromkeys(findings))
|
||||
if not findings:
|
||||
return None
|
||||
code = _dash_c_code(scan.command) if interpreter else None
|
||||
escalators = escalators_in_chain(argv)
|
||||
if escalators:
|
||||
findings.append(f"privilege escalation {', '.join(escalators)}")
|
||||
code = dash_c_code(scan.command) if interpreter else None
|
||||
code_index = scan.offset + code[0] if code is not None else None
|
||||
display = _format_argv_for_confirm(argv, code_index=code_index)
|
||||
reason = f"{via}: {' + '.join(findings)} — review before allow\n argv: {display}"
|
||||
@@ -83,7 +80,7 @@ def _command_reason(argv: Sequence[str], *, via: str) -> str | None:
|
||||
reason += f"\n command:\n{_indent_block(code[1])}"
|
||||
for span in scan.command_strings:
|
||||
reason += f"\n {span.label}:\n{_indent_block(span.text)}"
|
||||
return reason
|
||||
return DangerReason(text=reason, yolo_skip=not escalators)
|
||||
|
||||
|
||||
def _write_file_reason(args: Mapping[str, object]) -> str | None:
|
||||
@@ -136,10 +133,26 @@ def _delete_path_reason(args: Mapping[str, object]) -> str | None:
|
||||
path = args.get("path")
|
||||
recursive = bool(args.get("recursive", False))
|
||||
extra = " recursively" if recursive else ""
|
||||
return f"delete path {path!r}{extra}"
|
||||
if _delete_is_final(args):
|
||||
return f"delete path {path!r}{extra} permanently"
|
||||
return f"move path {path!r}{extra} to the system trash"
|
||||
|
||||
|
||||
def _run_argv_reason(args: Mapping[str, object]) -> str | None:
|
||||
def _delete_is_final(args: Mapping[str, object]) -> bool:
|
||||
"""True when the delete destroys the path instead of trashing it."""
|
||||
if bool(args.get("permanent", False)):
|
||||
return True
|
||||
try:
|
||||
if not get_trash_enabled():
|
||||
return True
|
||||
target = resolve_path(str(args.get("path", "")), required=AccessMode.WRITE)
|
||||
except WorkspaceError:
|
||||
# Path policy will refuse the call; word the confirm as the default.
|
||||
return False
|
||||
return under_system_temp(target)
|
||||
|
||||
|
||||
def _run_argv_reason(args: Mapping[str, object]) -> DangerReason | None:
|
||||
argv = _as_argv(args, key="argv")
|
||||
if argv is None:
|
||||
return None
|
||||
@@ -161,8 +174,12 @@ def _batch_step_argv(item: object) -> list[str] | None:
|
||||
return parts or None
|
||||
|
||||
|
||||
def _run_argv_batch_reason(args: Mapping[str, object]) -> str | None:
|
||||
"""One confirm for the whole batch if any step runs code or detaches."""
|
||||
def _run_argv_batch_reason(args: Mapping[str, object]) -> DangerReason | None:
|
||||
"""One confirm for the whole batch if any step runs code or detaches.
|
||||
|
||||
YOLO mode may drop it only when every step's reason allows that, so one
|
||||
escalating step keeps the whole batch in front of the human.
|
||||
"""
|
||||
raw = args.get("steps")
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
@@ -181,10 +198,13 @@ def _run_argv_batch_reason(args: Mapping[str, object]) -> str | None:
|
||||
if not risky:
|
||||
return None
|
||||
header = f"run_argv_batch: {len(risky)} risky step(s) (review before allow)"
|
||||
return header + "\n" + "\n".join(risky)
|
||||
return DangerReason(
|
||||
text=header + "\n" + "\n".join(reason.text for reason in risky),
|
||||
yolo_skip=all(reason.yolo_skip for reason in risky),
|
||||
)
|
||||
|
||||
|
||||
def _open_pty_reason(args: Mapping[str, object]) -> str | None:
|
||||
def _open_pty_reason(args: Mapping[str, object]) -> DangerReason | None:
|
||||
argv = _as_argv(args)
|
||||
if argv is None:
|
||||
return None
|
||||
@@ -204,14 +224,18 @@ def _fetch_reason(args: Mapping[str, object]) -> str | None:
|
||||
return fetch_soft_reason(method, url, body)
|
||||
|
||||
|
||||
def classify_danger(name: str, args: Mapping[str, object]) -> str | None: # noqa: PLR0911
|
||||
"""Return a short reason if ``name``/``args`` need user confirm, else ``None``.
|
||||
def classify_danger(name: str, args: Mapping[str, object]) -> str | DangerReason | None: # noqa: PLR0911
|
||||
"""Return why ``name``/``args`` need a user confirm, else ``None``.
|
||||
|
||||
Hard denylists (paths/commands) still raise independently. This only covers
|
||||
soft confirms for mutating tools and risky command launches — interpreters
|
||||
(however invoked) and detached runners such as ``nohup``, resolved past
|
||||
environment wrappers and ``<launcher> run``.
|
||||
Private/loopback fetch_local targets use a separate policy grant (not YOLO).
|
||||
|
||||
A reason also says whether YOLO mode may drop its prompt
|
||||
(:attr:`DangerReason.yolo_skip`); a command that escalates privileges
|
||||
refuses.
|
||||
"""
|
||||
if name == "delete_path":
|
||||
return _delete_path_reason(args)
|
||||
|
||||
@@ -11,10 +11,12 @@ from .listdir import listdir as listdir
|
||||
from .read import read_file as read_file
|
||||
from .regex_files import regex_files as regex_files
|
||||
from .tree import tree as tree
|
||||
from .view_image import view_image as view_image
|
||||
from .write import write_file as write_file
|
||||
|
||||
FILE_TOOLS = [
|
||||
read_file,
|
||||
view_image,
|
||||
write_file,
|
||||
listdir,
|
||||
tree,
|
||||
|
||||
@@ -4,7 +4,14 @@ import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.agent import ToolTag, invalidate_lineno_read, tool
|
||||
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_workspace_root, resolve_path
|
||||
from plyngent.tools.trash import move_to_trash, under_system_temp
|
||||
from plyngent.tools.workspace import (
|
||||
AccessMode,
|
||||
WorkspaceError,
|
||||
get_trash_enabled,
|
||||
get_workspace_root,
|
||||
resolve_path,
|
||||
)
|
||||
|
||||
|
||||
def _kind(path: Path) -> str:
|
||||
@@ -124,37 +131,47 @@ async def move_path(src: str, dst: str, *, overwrite: bool = False) -> str:
|
||||
return _copy_or_move_validated(source, dest, root, src=src, dst=dst, overwrite=overwrite, action="move")
|
||||
|
||||
|
||||
def _delete_directory(target: Path, path: str, *, recursive: bool) -> str:
|
||||
if recursive:
|
||||
shutil.rmtree(target)
|
||||
def _discard(target: Path, path: str, *, kind: str, permanent: bool, detail: str = "") -> str:
|
||||
"""Move *target* to the system trash (default), or delete it for good."""
|
||||
if permanent:
|
||||
_remove_existing(target)
|
||||
invalidate_lineno_read(str(target))
|
||||
return f"deleted directory {path} (recursive)"
|
||||
try:
|
||||
_ = next(target.iterdir())
|
||||
except StopIteration:
|
||||
target.rmdir()
|
||||
invalidate_lineno_read(str(target))
|
||||
return f"deleted empty directory {path}"
|
||||
return f"error: directory not empty: {path} (set recursive=true)"
|
||||
return f"deleted {kind} {path}{detail}"
|
||||
error = move_to_trash(target)
|
||||
if error is not None:
|
||||
return error
|
||||
invalidate_lineno_read(str(target))
|
||||
return f"deleted {kind} {path} (moved to system trash)"
|
||||
|
||||
|
||||
def _delete_target(target: Path, path: str, *, recursive: bool) -> str:
|
||||
def _delete_target(target: Path, path: str, *, recursive: bool, permanent: bool) -> str:
|
||||
if target.is_dir() and not target.is_symlink():
|
||||
if recursive:
|
||||
return _discard(target, path, kind="directory", permanent=permanent, detail=" (recursive)")
|
||||
try:
|
||||
_ = next(target.iterdir())
|
||||
except StopIteration:
|
||||
return _discard(target, path, kind="empty directory", permanent=permanent)
|
||||
return f"error: directory not empty: {path} (set recursive=true)"
|
||||
if target.is_symlink() or target.is_file():
|
||||
kind = _kind(target)
|
||||
target.unlink()
|
||||
invalidate_lineno_read(str(target))
|
||||
return f"deleted {kind} {path}"
|
||||
if target.is_dir():
|
||||
return _delete_directory(target, path, recursive=recursive)
|
||||
return _discard(target, path, kind=_kind(target), permanent=permanent)
|
||||
return f"error: unsupported path type: {path}"
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("path",))
|
||||
async def delete_path(path: str, *, recursive: bool = False) -> str:
|
||||
async def delete_path(path: str, *, recursive: bool = False, permanent: bool = False) -> str:
|
||||
"""Delete a file or directory under the workspace.
|
||||
|
||||
Files and empty directories are removed always. Non-empty directories require
|
||||
``recursive=true`` (uses ``shutil.rmtree``). The workspace root cannot be deleted.
|
||||
The path goes to the system trash (the desktop's own trash / Recycle Bin), so
|
||||
a deletion can be undone there; ``permanent=true`` removes it for good
|
||||
instead. Files and empty directories are removed always, while non-empty
|
||||
directories require ``recursive=true`` (uses ``shutil.rmtree``). The
|
||||
workspace root cannot be deleted. When the trash is unavailable for this
|
||||
path, nothing is deleted and the error says how to delete on purpose.
|
||||
|
||||
Paths under the OS temporary directory are deleted for good either way: they
|
||||
are scratch space, not something to restore. A host with ``[trash] enabled =
|
||||
false`` removes the path outright, as ``permanent=true`` does.
|
||||
"""
|
||||
try:
|
||||
target = resolve_path(path, required=AccessMode.WRITE)
|
||||
@@ -167,6 +184,11 @@ async def delete_path(path: str, *, recursive: bool = False) -> str:
|
||||
if target == root:
|
||||
return "error: cannot delete the workspace root"
|
||||
try:
|
||||
return _delete_target(target, path, recursive=recursive)
|
||||
return _delete_target(
|
||||
target,
|
||||
path,
|
||||
recursive=recursive,
|
||||
permanent=permanent or not get_trash_enabled() or under_system_temp(target),
|
||||
)
|
||||
except OSError as exc:
|
||||
return f"error: delete failed: {exc}"
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import cast
|
||||
|
||||
from plyngent.agent import ToolTag, mark_lineno_read, tool
|
||||
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
|
||||
from plyngent.agent.images import sniff_image_media_type
|
||||
from plyngent.tools.truncate_token import TruncateToken, truncate_with_token, truncation_marker
|
||||
from plyngent.tools.workspace import AccessMode, resolve_path
|
||||
|
||||
@@ -39,9 +40,15 @@ def read_raw_text(path: str) -> tuple[str | None, str]:
|
||||
|
||||
Shared by ``read_file`` and ``get_truncated`` so truncate-token char offsets
|
||||
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, and a picture
|
||||
is answered with the tool that shows it instead of decode noise.
|
||||
"""
|
||||
target = resolve_path(path, required=AccessMode.READ)
|
||||
if target.is_file():
|
||||
with target.open("rb") as handle:
|
||||
media_type = sniff_image_media_type(handle.read(16))
|
||||
if media_type is not None:
|
||||
return None, f"error: {path} is an image ({media_type}); use view_image to look at it"
|
||||
if not target.exists():
|
||||
return None, f"error: file not found: {path}"
|
||||
if not target.is_file():
|
||||
@@ -71,6 +78,8 @@ async def read_file(
|
||||
``DEFAULT_TOOL_RESULT_MAX_CHARS``); a ``truncate_token`` is appended when cut
|
||||
so ``get_truncated`` can continue reading the rest without a new request.
|
||||
Pass ``max_chars < 1`` to read unbounded.
|
||||
|
||||
An image file is refused with a pointer to ``view_image``, which shows it.
|
||||
"""
|
||||
target = resolve_path(path, required=AccessMode.READ)
|
||||
text, err = read_raw_text(path)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Model-facing ``view_image``: hand a local picture to the model as a picture."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from plyngent.agent import ToolOutput, ToolTag, tool
|
||||
from plyngent.agent.images import image_part_from_bytes
|
||||
from plyngent.tools.workspace import AccessMode, resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from plyngent.lmproto.openai_compatible.model import ImageDetail
|
||||
|
||||
_DETAILS: frozenset[str] = frozenset({"low", "high", "original", "auto"})
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||
async def view_image(path: str, detail: str = "auto") -> str | ToolOutput:
|
||||
"""Look at an image file (JPEG, PNG, GIF, or WebP) under the workspace.
|
||||
|
||||
Use it for a screenshot, a chart, a diagram, or any other picture, instead
|
||||
of ``read_file`` (which would return mojibake for those bytes).
|
||||
|
||||
``detail`` trades tokens for fine visual detail: ``low`` downscales to
|
||||
512x512 before inference — fast and cheap when only the layout matters, but
|
||||
small text stops being readable — while ``high``/``original`` keep the
|
||||
picture as it is, and ``auto`` (the default) lets the provider decide.
|
||||
|
||||
The format is read from the file's own bytes, so a misnamed file still
|
||||
works; anything else comes back as an error.
|
||||
"""
|
||||
if detail not in _DETAILS:
|
||||
return f"error: detail must be one of {', '.join(sorted(_DETAILS))}"
|
||||
target = resolve_path(path, required=AccessMode.READ)
|
||||
if not target.exists():
|
||||
return f"error: file not found: {path}"
|
||||
if not target.is_file():
|
||||
return f"error: not a file: {path}"
|
||||
part = image_part_from_bytes(target.read_bytes(), detail=cast("ImageDetail", detail))
|
||||
if isinstance(part, str):
|
||||
return f"error: {path}: {part.removeprefix('error: ')}"
|
||||
return ToolOutput(text=f"image: {path} (attached below)", images=[part])
|
||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent.tools import ToolDefinition, ToolTag
|
||||
from plyngent.agent.tools import ToolDefinition, ToolOutput, ToolTag
|
||||
from plyngent.tools.catalog import ToolSource, get_catalog
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -43,8 +43,8 @@ def _make_handler(
|
||||
manager: McpManager,
|
||||
server: str,
|
||||
tool: str,
|
||||
) -> Callable[..., Awaitable[str]]:
|
||||
async def handler(**arguments: object) -> str:
|
||||
) -> Callable[..., Awaitable[ToolOutput]]:
|
||||
async def handler(**arguments: object) -> ToolOutput:
|
||||
return await manager.call(server, tool, dict(arguments))
|
||||
|
||||
return handler
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import niquests
|
||||
|
||||
from plyngent.agent.images import sniff_image_media_type
|
||||
from plyngent.tools.net.grants import ensure_host_allowed
|
||||
from plyngent.tools.net.policy import (
|
||||
DEFAULT_MAX_BYTES,
|
||||
@@ -50,7 +51,10 @@ class FetchResult:
|
||||
method: str
|
||||
security: str
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
body_kind: str = "text" # text | binary | empty
|
||||
body_kind: str = "text" # text | binary | image | empty
|
||||
# Raw bytes, kept only for an image body so the caller can hand the picture
|
||||
# to the model instead of a hex preview.
|
||||
body_image: bytes = b""
|
||||
|
||||
|
||||
def _content_type(headers: Mapping[str, str] | object | None) -> str:
|
||||
@@ -132,6 +136,10 @@ def _body_payload(data: bytes, content_type: str) -> tuple[str, str]:
|
||||
return "empty", ""
|
||||
if _looks_text(content_type, data):
|
||||
return "text", _decode_body(data, content_type)
|
||||
if sniff_image_media_type(data) is not None:
|
||||
# An image is handed to the model as a picture, so the preview line only
|
||||
# carries what the part cannot say itself.
|
||||
return "image", f"(image body; content_type={content_type!r}; bytes={len(data)})"
|
||||
preview = data[:64].hex()
|
||||
return (
|
||||
"binary",
|
||||
@@ -279,6 +287,7 @@ async def http_fetch(
|
||||
),
|
||||
warnings=list(dict.fromkeys(warnings)),
|
||||
body_kind=body_kind,
|
||||
body_image=data if body_kind == "image" else b"",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.agent import ToolOutput, ToolTag, tool
|
||||
from plyngent.agent.images import image_part_from_bytes
|
||||
from plyngent.tools.context import is_read_only_context
|
||||
from plyngent.tools.net.client import http_fetch
|
||||
from plyngent.tools.net.client import FetchResult, http_fetch
|
||||
from plyngent.tools.net.policy import (
|
||||
DEFAULT_MAX_BODY_CHARS_IN,
|
||||
DEFAULT_MAX_BYTES,
|
||||
@@ -76,7 +77,7 @@ async def fetch_local(
|
||||
follow_redirects: bool = True,
|
||||
allow_http_downgrade: bool = False,
|
||||
offset: int = 0,
|
||||
) -> str:
|
||||
) -> str | ToolOutput:
|
||||
"""HTTP request (GET/POST/PUT/DELETE); return status, metadata, and truncated body.
|
||||
|
||||
Use for public docs/APIs or (after human policy allow) local/LAN servers.
|
||||
@@ -88,6 +89,9 @@ async def fetch_local(
|
||||
User-Agent entry in ``headers``. A headers-only User-Agent is kept as-is.
|
||||
When both omit UA, a small default is used.
|
||||
|
||||
An image response (JPEG, PNG, GIF, WebP) comes back as the picture itself
|
||||
beside that metadata, so you can look at it instead of reading a hex preview.
|
||||
|
||||
Private/loopback/link-local hosts require an explicit human policy grant
|
||||
(not skipped by YOLO). HTTPS→HTTP redirects are blocked unless
|
||||
``allow_http_downgrade`` is true.
|
||||
@@ -121,7 +125,7 @@ async def fetch_local(
|
||||
except Exception as exc: # noqa: BLE001 — tool surface returns error text
|
||||
return f"error: fetch_local failed: {exc}"
|
||||
|
||||
return format_fetch_result(
|
||||
text = format_fetch_result(
|
||||
status=result.status,
|
||||
final_url=result.final_url,
|
||||
content_type=result.content_type,
|
||||
@@ -136,6 +140,21 @@ async def fetch_local(
|
||||
max_chars=max_chars,
|
||||
offset=offset,
|
||||
)
|
||||
return _attach_image(text, result)
|
||||
|
||||
|
||||
def _attach_image(text: str, result: FetchResult) -> str | ToolOutput:
|
||||
"""An image response is handed over as a picture, beside the usual metadata.
|
||||
|
||||
The preview line stays in the text so ``body_kind=image`` and the byte count
|
||||
are still visible to the model.
|
||||
"""
|
||||
if result.body_kind != "image" or not result.body_image:
|
||||
return text
|
||||
part = image_part_from_bytes(result.body_image)
|
||||
if isinstance(part, str):
|
||||
return f"{text}\n{part}"
|
||||
return ToolOutput(text=text, images=[part])
|
||||
|
||||
|
||||
# Re-export for danger classifier without importing client.
|
||||
|
||||
@@ -6,6 +6,7 @@ import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.tools.trash import under_system_temp
|
||||
from plyngent.tools.workspace import (
|
||||
MAX_TEMPORARY_WORKSPACES,
|
||||
WorkspaceError,
|
||||
@@ -32,22 +33,14 @@ def _sanitize_prefix(prefix: str) -> str:
|
||||
return cleaned
|
||||
|
||||
|
||||
def _is_under_system_temp(path: Path) -> bool:
|
||||
"""True if *path* is under the OS temporary directory (safe to rmtree)."""
|
||||
try:
|
||||
temp_root = Path(tempfile.gettempdir()).expanduser().resolve()
|
||||
resolved = path.expanduser().resolve()
|
||||
_ = resolved.relative_to(temp_root)
|
||||
except OSError, ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def cleanup_temporary_workspaces() -> int:
|
||||
"""Remove directories created by :func:`new_temporary_workspace` (chat exit).
|
||||
|
||||
Only deletes paths still under the system temp dir. Returns the number of
|
||||
directories removed. No-op when no instance is bound.
|
||||
|
||||
These are scratch directories, so they are removed for good rather than
|
||||
moved to the trash the delete tool would use.
|
||||
"""
|
||||
from plyngent.tools.context import get_instance
|
||||
|
||||
@@ -55,7 +48,7 @@ def cleanup_temporary_workspaces() -> int:
|
||||
return 0
|
||||
removed = 0
|
||||
for path in pop_owned_temporary_workspaces():
|
||||
if not _is_under_system_temp(path):
|
||||
if not under_system_temp(path):
|
||||
remove_workspace_allowlist(path)
|
||||
continue
|
||||
if path.is_dir():
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Move a path to the system trash instead of destroying it.
|
||||
|
||||
``delete_path`` hands every removal to :func:`move_to_trash`, so a delete the
|
||||
human approved (or YOLO skipped) can still be undone from the desktop's own
|
||||
trash. The platform work is ``send2trash``: the freedesktop trash specification
|
||||
on Linux, the Finder trash on macOS, the Recycle Bin on Windows.
|
||||
|
||||
Scratch paths under the OS temporary directory are the exception — they are
|
||||
deleted for good, since nobody restores them and a trash on the ``/tmp`` volume
|
||||
would only collect them (see :func:`under_system_temp`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""True when ``send2trash`` can be imported.
|
||||
|
||||
It is a required dependency, so ``False`` means a broken install rather than
|
||||
an expected absence; the delete path then reports the problem instead of
|
||||
destroying the file.
|
||||
"""
|
||||
from importlib.util import find_spec
|
||||
|
||||
return find_spec("send2trash") is not None
|
||||
|
||||
|
||||
def under_system_temp(path: Path) -> bool:
|
||||
"""True if *path* is under the OS temporary directory (scratch space)."""
|
||||
try:
|
||||
temp_root = Path(tempfile.gettempdir()).expanduser().resolve()
|
||||
resolved = path.expanduser().resolve()
|
||||
_ = resolved.relative_to(temp_root)
|
||||
except OSError, ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def move_to_trash(path: Path) -> str | None:
|
||||
"""Send *path* to the system trash; return an error text, or ``None`` on success.
|
||||
|
||||
A failure is always reported, never downgraded to a permanent delete: the
|
||||
human was told the path is recoverable, so a trash that is unavailable for
|
||||
this path leaves the file untouched and says how to remove it on purpose.
|
||||
"""
|
||||
if not is_available():
|
||||
return "error: cannot move to trash: send2trash is not installed (use permanent=true)"
|
||||
|
||||
from send2trash import TrashPermissionError, send2trash
|
||||
|
||||
try:
|
||||
send2trash(str(path))
|
||||
except TrashPermissionError as exc:
|
||||
# A volume with no trash directory and no permission to create one.
|
||||
return f"error: cannot move to trash: {exc} (use permanent=true)"
|
||||
except OSError as exc:
|
||||
return f"error: cannot move to trash: {exc}"
|
||||
return None
|
||||
@@ -120,6 +120,8 @@ class WorkspacePolicy:
|
||||
# read them, nothing may write them. Not a grant, so they stay out of
|
||||
# ``/grants``; writes go through the owning tool's own confirm.
|
||||
static_read: list[Path] = field(default_factory=list)
|
||||
# ``[trash] enabled``: deletes go to the system trash unless cleared.
|
||||
trash_enabled: bool = True
|
||||
|
||||
|
||||
def _bound_instance() -> InstanceState | None:
|
||||
@@ -188,6 +190,16 @@ def get_path_denylist() -> tuple[str, ...]:
|
||||
return active_workspace_policy().path_denylist
|
||||
|
||||
|
||||
def set_trash_enabled(*, enabled: bool) -> None:
|
||||
"""Set whether ``delete_path`` moves to the system trash (``[trash]``)."""
|
||||
active_workspace_policy().trash_enabled = bool(enabled)
|
||||
|
||||
|
||||
def get_trash_enabled() -> bool:
|
||||
"""Whether deletes go to the system trash rather than being final."""
|
||||
return active_workspace_policy().trash_enabled
|
||||
|
||||
|
||||
def set_command_denylist(names: list[str] | tuple[str, ...] | frozenset[str] | None) -> None:
|
||||
"""Set denied command basenames (None restores defaults)."""
|
||||
policy = active_workspace_policy()
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Image parts: format sniffing, data URLs, and the inline size policy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
from plyngent.agent.images import (
|
||||
MAX_INLINE_IMAGE_BYTES,
|
||||
image_part_from_bytes,
|
||||
image_part_from_url,
|
||||
sniff_image_media_type,
|
||||
split_data_url,
|
||||
to_data_url,
|
||||
user_content,
|
||||
)
|
||||
from plyngent.lmproto.openai_compatible.model import ChatImageUrl, ChatTextPart
|
||||
|
||||
PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
def test_the_format_is_sniffed_from_the_bytes() -> None:
|
||||
assert sniff_image_media_type(PNG) == "image/png"
|
||||
assert sniff_image_media_type(b"\xff\xd8\xff\xe0JFIF") == "image/jpeg"
|
||||
assert sniff_image_media_type(b"GIF89a....") == "image/gif"
|
||||
assert sniff_image_media_type(b"RIFF\x00\x00\x00\x00WEBPVP8 ") == "image/webp"
|
||||
assert sniff_image_media_type(b"not an image") is None
|
||||
assert sniff_image_media_type(b"") is None
|
||||
|
||||
|
||||
def test_a_data_url_round_trips() -> None:
|
||||
url = to_data_url("image/png", PNG)
|
||||
|
||||
assert url.startswith("data:image/png;base64,")
|
||||
assert split_data_url(url) == ("image/png", base64.b64encode(PNG).decode("ascii"))
|
||||
assert split_data_url("https://example.com/a.png") is None
|
||||
|
||||
|
||||
def test_bytes_become_an_image_part_with_a_detail_level() -> None:
|
||||
part = image_part_from_bytes(PNG, detail="low")
|
||||
|
||||
assert not isinstance(part, str)
|
||||
assert part.image_url.detail == "low"
|
||||
assert part.image_url.url.startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
def test_non_image_bytes_are_an_error_text() -> None:
|
||||
result = image_part_from_bytes(b"plain text")
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "not a JPEG, PNG, GIF, or WebP image" in result
|
||||
|
||||
|
||||
def test_an_oversized_image_is_refused() -> None:
|
||||
result = image_part_from_bytes(PNG + b"\x00" * (MAX_INLINE_IMAGE_BYTES + 1))
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "inline limit" in result
|
||||
|
||||
|
||||
def test_a_url_becomes_an_image_part() -> None:
|
||||
part = image_part_from_url("https://example.com/a.jpg", detail="original")
|
||||
|
||||
assert part.image_url == ChatImageUrl(url="https://example.com/a.jpg", detail="original")
|
||||
|
||||
|
||||
def test_user_content_stays_a_string_without_images() -> None:
|
||||
assert user_content("hello") == "hello"
|
||||
assert user_content("hello", []) == "hello"
|
||||
|
||||
|
||||
def test_user_content_becomes_a_part_array_with_images() -> None:
|
||||
part = image_part_from_url("https://example.com/a.jpg")
|
||||
|
||||
content = user_content("look", [part])
|
||||
|
||||
assert content == [ChatTextPart(text="look"), part]
|
||||
# An image-only turn sends no empty text part.
|
||||
assert user_content("", [part]) == [part]
|
||||
@@ -20,10 +20,12 @@ from plyngent.agent.messages_bridge import (
|
||||
)
|
||||
from plyngent.lmproto.anthropic.model import (
|
||||
AnthropicAssistantMessage,
|
||||
AnthropicImageContent,
|
||||
AnthropicMessageResponse,
|
||||
AnthropicResponseText,
|
||||
AnthropicResponseToolUse,
|
||||
AnthropicServerToolUseContent,
|
||||
AnthropicTextContent,
|
||||
AnthropicThinkingContent,
|
||||
AnthropicToolDefinition,
|
||||
AnthropicToolResultContent,
|
||||
@@ -37,6 +39,9 @@ from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantFunctionTool,
|
||||
AssistantFunctionToolCall,
|
||||
ChatCompletionsParam,
|
||||
ChatImagePart,
|
||||
ChatImageUrl,
|
||||
ChatTextPart,
|
||||
DeveloperChatMessage,
|
||||
ReasoningEffort,
|
||||
SystemChatMessage,
|
||||
@@ -136,6 +141,57 @@ def test_merge_consecutive_tool_results() -> None:
|
||||
assert len(items[2].content) == 2
|
||||
|
||||
|
||||
def test_a_user_turn_carries_its_images_as_blocks() -> None:
|
||||
messages = [
|
||||
UserChatMessage(
|
||||
content=[
|
||||
ChatTextPart(text="what is this?"),
|
||||
ChatImagePart(image_url=ChatImageUrl(url="data:image/png;base64,AAAA", detail="low")),
|
||||
ChatImagePart(image_url=ChatImageUrl(url="https://example.com/a.jpg")),
|
||||
]
|
||||
)
|
||||
]
|
||||
_, items = chat_messages_to_anthropic(messages)
|
||||
|
||||
assert isinstance(items[0], AnthropicUserMessage)
|
||||
blocks = items[0].content
|
||||
assert isinstance(blocks, list)
|
||||
assert blocks[0] == AnthropicTextContent(text="what is this?")
|
||||
# An inline data URL becomes a base64 source carrying the sniffed media type.
|
||||
assert blocks[1] == AnthropicImageContent(source={"type": "base64", "media_type": "image/png", "data": "AAAA"})
|
||||
# A public link stays a url source.
|
||||
assert blocks[2] == AnthropicImageContent(source={"type": "url", "url": "https://example.com/a.jpg"})
|
||||
|
||||
|
||||
def test_a_tool_result_carries_its_images_inside_the_result() -> None:
|
||||
messages = [
|
||||
UserChatMessage(content="screenshot please"),
|
||||
AssistantChatMessage(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(id="c1", function=AssistantFunctionTool(name="view_image", arguments="{}"))
|
||||
],
|
||||
),
|
||||
ToolChatMessage(
|
||||
content="image: shot.png (2 KB)",
|
||||
tool_call_id="c1",
|
||||
images=[ChatImagePart(image_url=ChatImageUrl(url="data:image/png;base64,AAAA"))],
|
||||
),
|
||||
]
|
||||
_, items = chat_messages_to_anthropic(messages)
|
||||
|
||||
result = items[2]
|
||||
assert isinstance(result, AnthropicUserMessage)
|
||||
assert isinstance(result.content, list)
|
||||
block = result.content[0]
|
||||
assert isinstance(block, AnthropicToolResultContent)
|
||||
assert block.tool_use_id == "c1"
|
||||
assert block.content == [
|
||||
AnthropicTextContent(text="image: shot.png (2 KB)"),
|
||||
AnthropicImageContent(source={"type": "base64", "media_type": "image/png", "data": "AAAA"}),
|
||||
]
|
||||
|
||||
|
||||
def test_anthropic_response_to_assistant_with_tools() -> None:
|
||||
response = AnthropicMessageResponse(
|
||||
id="msg_1",
|
||||
|
||||
@@ -19,6 +19,9 @@ from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantFunctionTool,
|
||||
AssistantFunctionToolCall,
|
||||
ChatCompletionsParam,
|
||||
ChatImagePart,
|
||||
ChatImageUrl,
|
||||
ChatTextPart,
|
||||
DeveloperChatMessage,
|
||||
SystemChatMessage,
|
||||
ToolChatMessage,
|
||||
@@ -76,6 +79,62 @@ def test_chat_messages_to_input_and_instructions() -> None:
|
||||
assert _input_item_types(items) == ["message", "reasoning", "function_call", "function_call_output"]
|
||||
|
||||
|
||||
def test_a_user_turn_carries_its_images_as_input_parts() -> None:
|
||||
messages = [
|
||||
UserChatMessage(
|
||||
content=[
|
||||
ChatTextPart(text="what is this?"),
|
||||
ChatImagePart(image_url=ChatImageUrl(url="https://example.com/a.jpg", detail="low")),
|
||||
]
|
||||
)
|
||||
]
|
||||
_, items = chat_messages_to_responses_input(messages)
|
||||
|
||||
assert items[0] == ResponseEasyInputMessage(
|
||||
role="user",
|
||||
content=[
|
||||
{"type": "input_text", "text": "what is this?"},
|
||||
{"type": "input_image", "image_url": "https://example.com/a.jpg", "detail": "low"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_a_tool_output_carries_the_images_the_tool_returned() -> None:
|
||||
messages = [
|
||||
AssistantChatMessage(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(
|
||||
id="call_1", function=AssistantFunctionTool(name="view_image", arguments="{}")
|
||||
)
|
||||
],
|
||||
),
|
||||
ToolChatMessage(
|
||||
content="image: shot.png (2 KB)",
|
||||
tool_call_id="call_1",
|
||||
images=[ChatImagePart(image_url=ChatImageUrl(url="data:image/png;base64,AAAA"))],
|
||||
),
|
||||
]
|
||||
_, items = chat_messages_to_responses_input(messages)
|
||||
|
||||
output = items[-1]
|
||||
assert isinstance(output, ResponseFunctionToolCallOutput)
|
||||
assert output.call_id == "call_1"
|
||||
assert output.output == [
|
||||
{"type": "input_text", "text": "image: shot.png (2 KB)"},
|
||||
{"type": "input_image", "image_url": "data:image/png;base64,AAAA"},
|
||||
]
|
||||
|
||||
|
||||
def test_a_plain_tool_output_stays_a_string() -> None:
|
||||
messages = [ToolChatMessage(content="file body", tool_call_id="call_1")]
|
||||
_, items = chat_messages_to_responses_input(messages)
|
||||
|
||||
output = items[0]
|
||||
assert isinstance(output, ResponseFunctionToolCallOutput)
|
||||
assert output.output == "file body"
|
||||
|
||||
|
||||
def test_parallel_tool_outputs_group_after_the_rounds_calls() -> None:
|
||||
"""Regression: DeepSeek Responses rejected an interleaved batch.
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""A tool result that carries images beside its text."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from msgspec import UNSET
|
||||
|
||||
from plyngent.agent import ToolOutput, ToolRegistry, tool
|
||||
from plyngent.agent.budget import IMAGE_CHARS, estimate_message_chars
|
||||
from plyngent.agent.loop import _run_one_tool
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantFunctionTool,
|
||||
AssistantFunctionToolCall,
|
||||
ChatImagePart,
|
||||
ChatImageUrl,
|
||||
ChatTextPart,
|
||||
ToolChatMessage,
|
||||
UserChatMessage,
|
||||
)
|
||||
|
||||
|
||||
def _image() -> ChatImagePart:
|
||||
return ChatImagePart(image_url=ChatImageUrl(url="data:image/png;base64,AAAA", detail="low"))
|
||||
|
||||
|
||||
def _registry() -> ToolRegistry:
|
||||
@tool(register=False)
|
||||
def shot() -> ToolOutput:
|
||||
"""Pretend to take a screenshot."""
|
||||
return ToolOutput(text="image: shot.png (2 KB)", images=[_image()])
|
||||
|
||||
@tool(register=False)
|
||||
def plain() -> str:
|
||||
return "just text"
|
||||
|
||||
return ToolRegistry([shot, plain])
|
||||
|
||||
|
||||
def _call(name: str) -> AssistantFunctionToolCall:
|
||||
return AssistantFunctionToolCall(id="call-1", function=AssistantFunctionTool(name=name, arguments="{}"))
|
||||
|
||||
|
||||
async def test_execute_keeps_the_text_only_contract() -> None:
|
||||
registry = _registry()
|
||||
|
||||
assert await registry.execute("shot", "{}") == "image: shot.png (2 KB)"
|
||||
assert await registry.execute("plain", "{}") == "just text"
|
||||
|
||||
|
||||
async def test_execute_output_returns_the_images_beside_the_text() -> None:
|
||||
registry = _registry()
|
||||
output = await registry.execute_output("shot", "{}")
|
||||
|
||||
assert output.text == "image: shot.png (2 KB)"
|
||||
assert output.has_images
|
||||
assert output.images == [_image()]
|
||||
|
||||
|
||||
async def test_execute_output_wraps_a_plain_string_result() -> None:
|
||||
output = await _registry().execute_output("plain", "{}")
|
||||
|
||||
assert output.text == "just text"
|
||||
assert not output.has_images
|
||||
|
||||
|
||||
async def test_an_unknown_tool_is_error_text_without_images() -> None:
|
||||
output = await _registry().execute_output("missing", "{}")
|
||||
|
||||
assert "unknown tool" in output.text
|
||||
assert not output.has_images
|
||||
|
||||
|
||||
async def test_the_loop_keeps_tool_images_on_the_tool_message() -> None:
|
||||
message, err = await _run_one_tool(_registry(), _call("shot"), max_result_chars=32_000)
|
||||
|
||||
assert err is None
|
||||
assert isinstance(message, ToolChatMessage)
|
||||
assert message.content == "image: shot.png (2 KB)"
|
||||
assert message.images == [_image()]
|
||||
|
||||
|
||||
async def test_the_loop_leaves_the_images_field_unset_for_text_tools() -> None:
|
||||
message, err = await _run_one_tool(_registry(), _call("plain"), max_result_chars=32_000)
|
||||
|
||||
assert err is None
|
||||
assert message.images is UNSET
|
||||
|
||||
|
||||
async def test_a_truncated_tool_result_keeps_its_images() -> None:
|
||||
message, _ = await _run_one_tool(_registry(), _call("shot"), max_result_chars=1)
|
||||
|
||||
assert "[Truncated" in message.content
|
||||
assert message.images == [_image()]
|
||||
|
||||
|
||||
def test_the_char_estimate_counts_images() -> None:
|
||||
assert estimate_message_chars(UserChatMessage(content="look:")) == len("look:")
|
||||
assert estimate_message_chars(UserChatMessage(content=[ChatTextPart(text="look:"), _image()])) == (
|
||||
len("look:") + IMAGE_CHARS
|
||||
)
|
||||
assert estimate_message_chars(ToolChatMessage(content="shot", tool_call_id="call-1", images=[_image()])) == (
|
||||
len("shot") + len("call-1") + IMAGE_CHARS
|
||||
)
|
||||
@@ -93,6 +93,46 @@ async def test_round_asks_once_for_many_mutating_calls() -> None:
|
||||
assert single_prompts == []
|
||||
|
||||
|
||||
async def test_yolo_calls_are_left_out_of_the_prompt() -> None:
|
||||
"""Under YOLO mode a YOLO-tagged call runs unprompted, batch prompt included."""
|
||||
batch_prompts: list[list[str]] = []
|
||||
single_prompts: list[str] = []
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
|
||||
async def yolo_ok(path: str) -> str:
|
||||
return f"stamped {path}"
|
||||
|
||||
@tool(tags=ToolTag.LOCAL, register=False)
|
||||
async def always_ask(path: str) -> str:
|
||||
return f"stamped {path}"
|
||||
|
||||
def on_batch(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
|
||||
batch_prompts.append([request.name for request in requests])
|
||||
return BatchConfirmDecision(allowed=True)
|
||||
|
||||
async def on_single(name: str, _args: Mapping[str, object], _reason: str) -> bool:
|
||||
single_prompts.append(name)
|
||||
return True
|
||||
|
||||
reg = ToolRegistry(
|
||||
[yolo_ok, always_ask],
|
||||
danger=_danger,
|
||||
on_confirm=on_single,
|
||||
on_confirm_batch=on_batch,
|
||||
yolo=True,
|
||||
)
|
||||
calls = [("yolo_ok", '{"path": "a"}'), ("always_ask", '{"path": "b"}'), ("always_ask", '{"path": "c"}')]
|
||||
decisions = await reg.prepare_batch_confirm(calls)
|
||||
|
||||
assert decisions[0] is None
|
||||
assert batch_prompts == [["always_ask", "always_ask"]]
|
||||
results = [
|
||||
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
|
||||
]
|
||||
assert results == ["stamped a", "stamped b", "stamped c"]
|
||||
assert single_prompts == []
|
||||
|
||||
|
||||
async def test_single_request_keeps_the_one_call_prompt() -> None:
|
||||
"""One mutating call stays the plain per-call confirm."""
|
||||
batch_prompts: list[int] = []
|
||||
|
||||
@@ -657,6 +657,12 @@ async def test_pretty_mutators(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
),
|
||||
("move_path", '{"src": "src", "dst": "dst"}', "moved directory src -> dst", "* Move 'src' → 'dst' (done)"),
|
||||
("delete_path", '{"path": "tmp/x.txt"}', "deleted file tmp/x.txt", "* Delete 'tmp/x.txt' (done)"),
|
||||
(
|
||||
"delete_path",
|
||||
'{"path": "tmp/x.txt"}',
|
||||
"deleted file tmp/x.txt (moved to system trash)",
|
||||
"* Delete 'tmp/x.txt' (to trash)",
|
||||
),
|
||||
]
|
||||
for name, args, content, expected in cases:
|
||||
await render_events(_aiter([_pretty_call(name, args), _result(content)]))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
@@ -147,7 +148,10 @@ async def test_config_allow_paths_installed(
|
||||
) -> 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')
|
||||
# JSON string escaping is TOML escaping too, so a Windows path's backslashes
|
||||
# survive instead of reading as an escape (``\u``).
|
||||
key = json.dumps(str(outside))
|
||||
document = tomlkit.parse(f'[agent]\nallow_paths = {{ {key} = "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}
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -35,6 +36,14 @@ if TYPE_CHECKING:
|
||||
|
||||
import pytest as pytest_module
|
||||
|
||||
# The router is asyncio's SIGINT handler, which only exists where signals do; on
|
||||
# Windows a test could not deliver one either — ``os.kill(pid, SIGINT)`` is
|
||||
# ``TerminateProcess`` there, so it kills the runner instead of interrupting it.
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="SIGINT routing needs POSIX signal handling",
|
||||
)
|
||||
|
||||
|
||||
class FakeTarget:
|
||||
"""Minimal ``InterruptTarget`` recording its cancels."""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
@@ -15,12 +16,18 @@ from plyngent.cli.limits import (
|
||||
reset_auto_continue_turn,
|
||||
set_auto_continue_default,
|
||||
)
|
||||
from plyngent.prompting import NonInteractiveBackend, get_prompt_backend, get_prompt_output_hook, temporary_backend
|
||||
from plyngent.prompting import (
|
||||
NonInteractiveBackend,
|
||||
get_prompt_backend,
|
||||
get_prompt_output_hook,
|
||||
get_prompt_wait_hook,
|
||||
temporary_backend,
|
||||
)
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
from tests.test_prompting import ScriptedBackend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Generator, Iterator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -84,13 +91,28 @@ def test_prompt_continue_limit_noninteractive_denies() -> None:
|
||||
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
|
||||
from plyngent.config.models import NotifyConfig
|
||||
|
||||
_setup_hooks(interactive=False, auto_continue=True)
|
||||
_setup_hooks(interactive=False, auto_continue=True, notify=NotifyConfig())
|
||||
assert PtyManager._limit_continue is not None
|
||||
_setup_hooks(interactive=False, auto_continue=False)
|
||||
_setup_hooks(interactive=False, auto_continue=False, notify=NotifyConfig())
|
||||
assert PtyManager._limit_continue is None
|
||||
|
||||
|
||||
def test_setup_hooks_installs_the_notifier_only_for_an_interactive_run(clean_auto_continue: None) -> None:
|
||||
"""A one-shot run never waits on a human, so it installs no wait hook."""
|
||||
del clean_auto_continue
|
||||
from plyngent.cli.app import _setup_hooks
|
||||
from plyngent.config.models import NotifyConfig
|
||||
|
||||
_setup_hooks(interactive=False, auto_continue=False, notify=NotifyConfig())
|
||||
assert get_prompt_wait_hook() is None
|
||||
_setup_hooks(interactive=True, auto_continue=False, notify=NotifyConfig())
|
||||
assert get_prompt_wait_hook() is not None
|
||||
_setup_hooks(interactive=False, auto_continue=False, notify=NotifyConfig())
|
||||
assert get_prompt_wait_hook() 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)
|
||||
@@ -157,15 +179,134 @@ def test_directory_access_prompt_yes_and_level(monkeypatch: pytest.MonkeyPatch)
|
||||
|
||||
target = Path("/tmp")
|
||||
with temporary_backend(ScriptedBackend([])):
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "y\n")
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "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")
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "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")
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "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)
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: None)
|
||||
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) is None
|
||||
|
||||
|
||||
class _AnswerSource:
|
||||
"""A host surface offering answers to a waiting prompt."""
|
||||
|
||||
supports_actions = True
|
||||
|
||||
def __init__(self, *answers: str) -> None:
|
||||
self._answers = list(answers)
|
||||
|
||||
def take(self) -> str | None:
|
||||
return self._answers.pop(0) if self._answers else None
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _answered(*answers: str) -> Generator[_AnswerSource]:
|
||||
"""Install a wait hook whose prompt can be answered by the notification."""
|
||||
from plyngent.prompting import set_prompt_wait_hook
|
||||
|
||||
source = _AnswerSource(*answers)
|
||||
previous = get_prompt_wait_hook()
|
||||
set_prompt_wait_hook(lambda _wait: contextlib.nullcontext(source))
|
||||
try:
|
||||
yield source
|
||||
finally:
|
||||
set_prompt_wait_hook(previous)
|
||||
|
||||
|
||||
def test_directory_access_takes_a_notification_answer() -> None:
|
||||
"""``allow`` from the notification approves the requested level."""
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.cli import limits
|
||||
from plyngent.tools.access import AccessDecision
|
||||
from plyngent.tools.workspace import AccessMode
|
||||
|
||||
with temporary_backend(ScriptedBackend([])), _answered("allow"):
|
||||
assert limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0) == AccessDecision(
|
||||
AccessMode.READ
|
||||
)
|
||||
|
||||
|
||||
def test_directory_access_denied_by_the_notification() -> None:
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.cli import limits
|
||||
from plyngent.tools.workspace import AccessMode
|
||||
|
||||
with temporary_backend(ScriptedBackend([])), _answered("deny"):
|
||||
assert limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0) is None
|
||||
|
||||
|
||||
def test_tool_confirm_takes_a_notification_answer() -> None:
|
||||
with temporary_backend(ScriptedBackend([])), _answered("allow"):
|
||||
assert prompt_confirm_tool("run_argv", {}, "runs a command") is True
|
||||
|
||||
|
||||
def test_tool_confirm_denied_by_the_notification() -> None:
|
||||
with temporary_backend(ScriptedBackend([""])), _answered("deny"):
|
||||
assert prompt_confirm_tool("run_argv", {}, "runs a command") is False
|
||||
|
||||
|
||||
def test_batch_confirm_takes_a_notification_answer() -> None:
|
||||
from plyngent.agent import ToolConfirmRequest
|
||||
from plyngent.cli.limits import _prompt_confirm_batch_sync
|
||||
|
||||
requests = [
|
||||
ToolConfirmRequest(name="run_argv", args={}, reason="one"),
|
||||
ToolConfirmRequest(name="run_argv", args={}, reason="two"),
|
||||
]
|
||||
with temporary_backend(ScriptedBackend([])), _answered("allow"):
|
||||
assert _prompt_confirm_batch_sync(requests).allowed is True
|
||||
|
||||
|
||||
def test_batch_confirm_denied_by_the_notification() -> None:
|
||||
from plyngent.agent import ToolConfirmRequest
|
||||
from plyngent.cli.limits import _prompt_confirm_batch_sync
|
||||
|
||||
requests = [
|
||||
ToolConfirmRequest(name="run_argv", args={}, reason="one"),
|
||||
ToolConfirmRequest(name="run_argv", args={}, reason="two"),
|
||||
]
|
||||
with temporary_backend(ScriptedBackend([""])), _answered("deny"):
|
||||
assert _prompt_confirm_batch_sync(requests).allowed is False
|
||||
|
||||
|
||||
def test_every_confirm_announces_itself() -> None:
|
||||
"""Each blocking confirm reports its kind, so a host can pick a surface."""
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.agent import ToolConfirmRequest
|
||||
from plyngent.cli import limits
|
||||
from plyngent.tools.workspace import AccessMode
|
||||
|
||||
kinds: list[str] = []
|
||||
|
||||
@contextlib.contextmanager
|
||||
def hook(wait: object) -> Generator[None]:
|
||||
kinds.append(wait.kind) # type: ignore[attr-defined]
|
||||
yield None
|
||||
|
||||
from plyngent.prompting import set_prompt_wait_hook
|
||||
|
||||
previous = get_prompt_wait_hook()
|
||||
set_prompt_wait_hook(hook)
|
||||
try:
|
||||
with temporary_backend(ScriptedBackend(["y", "y", "y"], confirms=[True])):
|
||||
_ = prompt_confirm_tool("run_argv", {}, "reason")
|
||||
_ = limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0)
|
||||
_ = limits._prompt_confirm_batch_sync(
|
||||
[
|
||||
ToolConfirmRequest(name="run_argv", args={}, reason="one"),
|
||||
ToolConfirmRequest(name="run_argv", args={}, reason="two"),
|
||||
]
|
||||
)
|
||||
_ = prompt_continue_limit("limit")
|
||||
finally:
|
||||
set_prompt_wait_hook(previous)
|
||||
assert kinds == ["confirm", "access", "batch", "limit"]
|
||||
|
||||
@@ -68,7 +68,7 @@ async def test_slash_mcp_list_statuses(state: ReplState, capsys: pytest.CaptureF
|
||||
out = capsys.readouterr().out
|
||||
assert "fake" in out
|
||||
assert "connected" in out
|
||||
assert "tools=3" in out
|
||||
assert "tools=4" in out
|
||||
finally:
|
||||
await manager.aclose()
|
||||
|
||||
@@ -92,7 +92,7 @@ async def test_slash_mcp_reconnect_connects_new_config(
|
||||
statuses = state.mcp_manager.statuses()
|
||||
assert [name for name, _, _ in statuses] == ["fake"]
|
||||
assert statuses[0][1] == "connected"
|
||||
assert statuses[0][2] == 3
|
||||
assert statuses[0][2] == 4
|
||||
out = capsys.readouterr().out
|
||||
assert "tools=off" in out
|
||||
assert "fake" in out
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""``@path`` mentions: parsing, token spelling, and Tab completion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.cli.mentions import (
|
||||
complete_mention_paths,
|
||||
mention_completions,
|
||||
mention_token_for,
|
||||
parse_mentions,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _paths(line: str) -> list[str]:
|
||||
return [mention.path for mention in parse_mentions(line)]
|
||||
|
||||
|
||||
def test_a_mention_is_a_whitespace_delimited_token() -> None:
|
||||
assert _paths("look at @shot.png please") == ["shot.png"]
|
||||
assert _paths("@a.png and @b/c.txt") == ["a.png", "b/c.txt"]
|
||||
assert _paths("@a.png\n@b.png") == ["a.png", "b.png"]
|
||||
|
||||
|
||||
def test_an_email_address_is_not_a_mention() -> None:
|
||||
assert _paths("mail me at someone@example.com") == []
|
||||
|
||||
|
||||
def test_a_quoted_mention_may_hold_spaces() -> None:
|
||||
assert _paths('look at @"screens/my shot.png"') == ["screens/my shot.png"]
|
||||
assert _paths('@"a b.png" and @c.png') == ["a b.png", "c.png"]
|
||||
|
||||
|
||||
def test_an_unterminated_quote_is_left_alone() -> None:
|
||||
assert _paths('still typing @"my sh') == []
|
||||
|
||||
|
||||
def test_a_bare_at_sign_is_not_a_mention() -> None:
|
||||
assert _paths("@ and @@@") == []
|
||||
|
||||
|
||||
def test_a_token_quotes_a_path_with_spaces() -> None:
|
||||
assert mention_token_for("shot.png") == "@shot.png"
|
||||
assert mention_token_for("my shot.png") == '@"my shot.png"'
|
||||
|
||||
|
||||
def test_completion_lists_a_directory(tmp_path: Path) -> None:
|
||||
(tmp_path / "shot.png").write_bytes(b"")
|
||||
(tmp_path / "screens").mkdir()
|
||||
|
||||
completions = complete_mention_paths("sc", cwd=tmp_path)
|
||||
|
||||
assert completions == ["screens/"]
|
||||
|
||||
|
||||
def test_completion_continues_through_a_directory(tmp_path: Path) -> None:
|
||||
(tmp_path / "screens").mkdir()
|
||||
(tmp_path / "screens" / "shot.png").write_bytes(b"")
|
||||
|
||||
assert complete_mention_paths("screens/sh", cwd=tmp_path) == ["screens/shot.png"]
|
||||
assert complete_mention_paths("screens/", cwd=tmp_path) == ["screens/shot.png"]
|
||||
|
||||
|
||||
def test_completion_hides_dot_files_unless_asked(tmp_path: Path) -> None:
|
||||
(tmp_path / ".env").write_text("x", encoding="utf-8")
|
||||
|
||||
assert complete_mention_paths("", cwd=tmp_path) == []
|
||||
assert complete_mention_paths(".", cwd=tmp_path) == [".env"]
|
||||
|
||||
|
||||
def test_completion_ignores_a_missing_directory(tmp_path: Path) -> None:
|
||||
assert complete_mention_paths("nope/x", cwd=tmp_path) == []
|
||||
|
||||
|
||||
def test_completions_keep_the_at_sign_and_quote_spaces(tmp_path: Path) -> None:
|
||||
(tmp_path / "my shot.png").write_bytes(b"")
|
||||
(tmp_path / "shots").mkdir()
|
||||
|
||||
assert mention_completions("@my", cwd=tmp_path) == ['@"my shot.png"']
|
||||
assert mention_completions("@shots", cwd=tmp_path) == ["@shots/"]
|
||||
assert mention_completions("@", cwd=tmp_path) == ['@"my shot.png"', "@shots/"]
|
||||
|
||||
|
||||
def test_mention_completions_ignores_a_plain_word() -> None:
|
||||
assert mention_completions("shot") == []
|
||||
@@ -0,0 +1,383 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.cli.notify import (
|
||||
ALLOW,
|
||||
DENY,
|
||||
REVIEW,
|
||||
DesktopRequest,
|
||||
WaitNotifier,
|
||||
detect_multiplexer,
|
||||
detect_protocol,
|
||||
install_notifier,
|
||||
terminal_sequence,
|
||||
)
|
||||
from plyngent.config.models import NotifyConfig
|
||||
from plyngent.prompting import (
|
||||
PromptWait,
|
||||
configure_prompting,
|
||||
get_prompt_wait_hook,
|
||||
reset_prompting,
|
||||
temporary_backend,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
class _InteractiveBackend:
|
||||
"""An interactive backend that never actually reads (the prompts do not run)."""
|
||||
|
||||
def is_interactive(self) -> bool:
|
||||
return True
|
||||
|
||||
def read_line(self, prompt: str, *, default: str | None = None, completions: object = None) -> str:
|
||||
del prompt, completions
|
||||
return default or ""
|
||||
|
||||
def read_secret_line(self, prompt: str) -> str:
|
||||
del prompt
|
||||
return ""
|
||||
|
||||
def confirm(self, prompt: str, *, default: bool = False) -> bool:
|
||||
del prompt
|
||||
return default
|
||||
|
||||
def echo(self, message: str = "", *, err: bool = False) -> None:
|
||||
del message, err
|
||||
|
||||
def secho(self, message: str, *, fg: str | None = None, err: bool = False) -> None:
|
||||
del message, fg, err
|
||||
|
||||
|
||||
class _FakeDesktop:
|
||||
"""A desktop channel that records what it was asked to show."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
capabilities: frozenset[str] | None = None,
|
||||
*,
|
||||
fail: bool = False,
|
||||
) -> None:
|
||||
self.caps = frozenset({"buttons", "reply"}) if capabilities is None else capabilities
|
||||
self.fail = fail
|
||||
self.requests: list[DesktopRequest] = []
|
||||
self.cleared: list[str | None] = []
|
||||
|
||||
def capabilities(self) -> frozenset[str]:
|
||||
return self.caps
|
||||
|
||||
def send(self, request: DesktopRequest) -> str | None:
|
||||
if self.fail:
|
||||
msg = "no notification server"
|
||||
raise RuntimeError(msg)
|
||||
self.requests.append(request)
|
||||
return "id-1"
|
||||
|
||||
def clear(self, identifier: str | None) -> None:
|
||||
self.cleared.append(identifier)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _prompting_state() -> Iterator[None]:
|
||||
reset_prompting()
|
||||
configure_prompting(backend=_InteractiveBackend())
|
||||
yield
|
||||
reset_prompting()
|
||||
|
||||
|
||||
def _notifier(
|
||||
*,
|
||||
desktop: _FakeDesktop | None = None,
|
||||
grace_seconds: float = 60.0,
|
||||
min_interval_seconds: float = 0.0,
|
||||
**kwargs: object,
|
||||
) -> tuple[WaitNotifier, list[str]]:
|
||||
"""A notifier with a captured terminal writer and a fake desktop channel."""
|
||||
written: list[str] = []
|
||||
config = NotifyConfig(
|
||||
grace_seconds=grace_seconds,
|
||||
min_interval_seconds=min_interval_seconds,
|
||||
**kwargs, # type: ignore[arg-type]
|
||||
)
|
||||
notifier = WaitNotifier(
|
||||
config,
|
||||
desktop=desktop,
|
||||
write=written.append,
|
||||
env={"TERM_PROGRAM": "WezTerm"},
|
||||
)
|
||||
return notifier, written
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Terminal channel
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_detect_protocol_reads_the_environment() -> None:
|
||||
assert detect_protocol({"TERM_PROGRAM": "iTerm.app"}) == "osc9"
|
||||
assert detect_protocol({"TERM_PROGRAM": "Apple_Terminal"}) == "osc9"
|
||||
assert detect_protocol({"TERM_PROGRAM": "WezTerm"}) == "osc777"
|
||||
assert detect_protocol({"TERM_PROGRAM": "ghostty"}) == "osc777"
|
||||
assert detect_protocol({"WT_SESSION": "1"}) == "osc777"
|
||||
assert detect_protocol({"VTE_VERSION": "5000"}) == "osc777"
|
||||
assert detect_protocol({"TERM": "rxvt-unicode"}) == "osc777"
|
||||
# kitty speaks OSC 99 only, which this module does not implement.
|
||||
assert detect_protocol({"KITTY_WINDOW_ID": "1"}) == "bell"
|
||||
assert detect_protocol({"TERM": "dumb"}) == "bell"
|
||||
|
||||
|
||||
def test_detect_protocol_honours_the_override() -> None:
|
||||
assert detect_protocol({"TERM_PROGRAM": "WezTerm"}, "osc9") == "osc9"
|
||||
assert detect_protocol({"TERM_PROGRAM": "iTerm.app"}, "bell") == "bell"
|
||||
# An unknown override falls back to detection.
|
||||
assert detect_protocol({"TERM_PROGRAM": "WezTerm"}, "osc99") == "osc777"
|
||||
|
||||
|
||||
def test_detect_multiplexer() -> None:
|
||||
assert detect_multiplexer({"TMUX": "/tmp/tmux-1000/default,1,0"}) == "tmux"
|
||||
assert detect_multiplexer({"STY": "1234.pts-0.host"}) == "screen"
|
||||
assert detect_multiplexer({}) == ""
|
||||
|
||||
|
||||
def test_terminal_sequence_osc777_and_osc9() -> None:
|
||||
assert terminal_sequence("osc777", "T", "M") == "\x1b]777;notify;T;M\x07"
|
||||
assert terminal_sequence("osc9", "T", "M") == "\x1b]9;T: M\x07"
|
||||
assert terminal_sequence("bell", "T", "M") == "\a"
|
||||
|
||||
|
||||
def test_terminal_sequence_is_wrapped_for_tmux() -> None:
|
||||
seq = terminal_sequence("osc777", "T", "M", multiplexer="tmux")
|
||||
assert seq.startswith("\x1bPtmux;\x1b")
|
||||
assert seq.endswith("\x1b\\")
|
||||
# The inner ESCs are doubled so tmux forwards them verbatim.
|
||||
assert "\x1b\x1b]777" in seq
|
||||
|
||||
|
||||
def test_terminal_sequence_strips_control_characters() -> None:
|
||||
seq = terminal_sequence("osc777", "a\x07b\nc", "d\te")
|
||||
assert "\x07" not in seq.removesuffix("\x07")
|
||||
assert "ab c" in seq
|
||||
assert "\n" not in seq
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Grace, throttle, channels
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_nothing_is_shown_before_the_grace_period() -> None:
|
||||
notifier, written = _notifier(grace_seconds=0.05)
|
||||
with notifier(PromptWait("confirm", "Allow?")):
|
||||
pass
|
||||
time.sleep(0.1)
|
||||
assert written == []
|
||||
|
||||
|
||||
def test_a_waited_prompt_is_announced() -> None:
|
||||
notifier, written = _notifier(grace_seconds=0.01)
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
deadline = time.monotonic() + 1.0
|
||||
while not written and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert handle.supports_actions is False # no desktop channel here
|
||||
assert written
|
||||
assert "777" in written[0]
|
||||
assert "Allow?" in written[0]
|
||||
|
||||
|
||||
def test_terminal_channel_can_be_pinned() -> None:
|
||||
desktop = _FakeDesktop()
|
||||
notifier, written = _notifier(desktop=desktop, channels=["terminal"])
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
assert desktop.requests == []
|
||||
assert written
|
||||
|
||||
|
||||
def test_desktop_channel_can_be_pinned() -> None:
|
||||
desktop = _FakeDesktop()
|
||||
notifier, written = _notifier(desktop=desktop, channels=["desktop"])
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
assert len(desktop.requests) == 1
|
||||
assert written == []
|
||||
|
||||
|
||||
def test_terminal_fallback_when_the_desktop_refuses() -> None:
|
||||
desktop = _FakeDesktop(fail=True)
|
||||
notifier, written = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
assert written
|
||||
|
||||
|
||||
def test_a_burst_is_throttled() -> None:
|
||||
notifier, written = _notifier(grace_seconds=0.0, min_interval_seconds=60.0)
|
||||
for _ in range(2):
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
assert len(written) == 1
|
||||
|
||||
|
||||
def test_nothing_is_shown_on_a_non_interactive_prompt() -> None:
|
||||
from plyngent.prompting import NonInteractiveBackend
|
||||
|
||||
notifier, written = _notifier()
|
||||
configure_prompting(backend=NonInteractiveBackend())
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
assert written == []
|
||||
|
||||
|
||||
def test_disabled_notifications_send_nothing() -> None:
|
||||
notifier, written = _notifier(grace_seconds=0.0, enabled=False)
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
assert written == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Answers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_a_confirmation_offers_three_buttons() -> None:
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
assert handle.supports_actions is True
|
||||
request = desktop.requests[0]
|
||||
assert [key for key, _ in request.buttons] == [ALLOW, DENY, REVIEW]
|
||||
assert request.reply_field is False
|
||||
|
||||
|
||||
def test_an_ask_offers_a_reply_field_instead_of_buttons() -> None:
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("ask", "Which branch?")) as handle:
|
||||
handle._notify()
|
||||
request = desktop.requests[0]
|
||||
assert request.reply_field is True
|
||||
assert request.buttons == ()
|
||||
|
||||
|
||||
def test_a_limit_prompt_carries_no_answers() -> None:
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("limit", "Round limit reached")) as handle:
|
||||
handle._notify()
|
||||
assert handle.supports_actions is False
|
||||
assert desktop.requests[0].buttons == ()
|
||||
assert desktop.requests[0].reply_field is False
|
||||
|
||||
|
||||
def test_actions_can_be_switched_off() -> None:
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop, actions=False)
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
assert handle.supports_actions is False
|
||||
assert desktop.requests[0].buttons == ()
|
||||
|
||||
|
||||
def test_platform_without_buttons_gets_a_text_notification() -> None:
|
||||
desktop = _FakeDesktop(capabilities=frozenset())
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
assert handle.supports_actions is False
|
||||
assert desktop.requests[0].buttons == ()
|
||||
|
||||
|
||||
def test_a_button_answer_reaches_the_prompt() -> None:
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
request = desktop.requests[0]
|
||||
assert request.on_answer is not None
|
||||
request.on_answer(ALLOW)
|
||||
assert handle.take() == ALLOW
|
||||
assert handle.take() is None
|
||||
|
||||
|
||||
def test_a_reply_reaches_the_prompt() -> None:
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("ask", "Which branch?")) as handle:
|
||||
handle._notify()
|
||||
request = desktop.requests[0]
|
||||
assert request.on_reply is not None
|
||||
request.on_reply("feature/x")
|
||||
assert handle.take() == "feature/x"
|
||||
|
||||
|
||||
def test_review_keeps_waiting_and_dismisses() -> None:
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
request = desktop.requests[0]
|
||||
assert request.on_answer is not None
|
||||
request.on_answer(REVIEW)
|
||||
assert handle.take() is None
|
||||
assert desktop.cleared == ["id-1"]
|
||||
|
||||
|
||||
def test_an_answer_after_the_prompt_is_ignored() -> None:
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
request = desktop.requests[0]
|
||||
assert request.on_answer is not None
|
||||
request.on_answer(ALLOW)
|
||||
assert handle.take() is None
|
||||
|
||||
|
||||
def test_the_notification_is_dismissed_when_the_prompt_returns() -> None:
|
||||
desktop = _FakeDesktop()
|
||||
notifier, _ = _notifier(desktop=desktop)
|
||||
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||
handle._notify()
|
||||
assert handle.identifier == "id-1"
|
||||
assert handle.identifier is None
|
||||
assert desktop.cleared == ["id-1"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Installation
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_install_notifier_sets_the_hook() -> None:
|
||||
notifier = install_notifier(NotifyConfig())
|
||||
assert notifier is not None
|
||||
assert get_prompt_wait_hook() is not None
|
||||
|
||||
|
||||
def test_install_notifier_can_be_disabled() -> None:
|
||||
assert install_notifier(NotifyConfig(enabled=False)) is None
|
||||
assert get_prompt_wait_hook() is None
|
||||
|
||||
|
||||
def test_reason_stays_out_unless_asked_for() -> None:
|
||||
notifier, _ = _notifier()
|
||||
wait = PromptWait("confirm", "Allow?", "argv: rm -rf /")
|
||||
assert notifier._message(wait) == "Allow?"
|
||||
notifier, _ = _notifier(grace_seconds=0.0, include_reason=True)
|
||||
assert "rm -rf" in notifier._message(wait)
|
||||
|
||||
|
||||
def test_temporary_backend_is_transparent_to_the_notifier() -> None:
|
||||
"""The notifier is only a hook: without installation nothing is shown."""
|
||||
notifier, written = _notifier()
|
||||
with temporary_backend(_InteractiveBackend()), notifier(PromptWait("confirm", "Allow?")):
|
||||
pass
|
||||
assert written == []
|
||||
@@ -170,6 +170,46 @@ def test_completer_stream_on_off(tmp_path: object, monkeypatch: object) -> None:
|
||||
assert completer("o", 1) == "off"
|
||||
|
||||
|
||||
def test_completer_path_mentions(tmp_path: object, monkeypatch: object) -> None:
|
||||
"""An ``@path`` token completes files, wherever it sits in the line."""
|
||||
import readline
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
assert isinstance(tmp_path, Path)
|
||||
assert isinstance(monkeypatch, pytest.MonkeyPatch)
|
||||
(tmp_path / "shot.png").write_bytes(b"")
|
||||
(tmp_path / "screens").mkdir()
|
||||
|
||||
state = _minimal_state(tmp_path)
|
||||
completer = build_completer(state)
|
||||
monkeypatch.setattr(readline, "get_line_buffer", lambda: "look at @sh")
|
||||
monkeypatch.setattr(readline, "get_begidx", lambda: len("look at "))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
found = [item for index in range(10) if (item := completer("@sh", index)) is not None]
|
||||
assert found == ["@shot.png"]
|
||||
|
||||
|
||||
def test_a_plain_word_is_still_a_slash_argument(tmp_path: object, monkeypatch: object) -> None:
|
||||
"""The mention branch must not swallow ordinary argument completion."""
|
||||
import readline
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
assert isinstance(tmp_path, Path)
|
||||
assert isinstance(monkeypatch, pytest.MonkeyPatch)
|
||||
|
||||
state = _minimal_state(tmp_path)
|
||||
completer = build_completer(state)
|
||||
monkeypatch.setattr(readline, "get_line_buffer", lambda: "/provider r")
|
||||
monkeypatch.setattr(readline, "get_begidx", lambda: len("/provider "))
|
||||
|
||||
assert completer("r", 0) == "remote"
|
||||
|
||||
|
||||
def test_complete_slash_args_from_registry(tmp_path: object) -> None:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import TYPE_CHECKING, Literal, overload
|
||||
|
||||
import pytest
|
||||
import tomlkit
|
||||
|
||||
from plyngent.agent import ChatAgent, Notice
|
||||
from plyngent.cli.mentions import resolve_mentions
|
||||
from plyngent.cli.repl import _send_turn
|
||||
from plyngent.cli.slash import handle_slash
|
||||
from plyngent.cli.state import ReplState
|
||||
from plyngent.config.models import DatabaseConfig, OpenAIProvider
|
||||
from plyngent.config.models import DatabaseConfig, ModelConfig, OpenAIProvider
|
||||
from plyngent.config.store import ConfigStore
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantChatMessage,
|
||||
@@ -16,6 +19,9 @@ from plyngent.lmproto.openai_compatible.model import (
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionResponse,
|
||||
ChatCompletionsParam,
|
||||
ChatImagePart,
|
||||
ChatTextPart,
|
||||
UserChatMessage,
|
||||
)
|
||||
from plyngent.memory import MemoryStore
|
||||
|
||||
@@ -24,6 +30,17 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# A real 1x1 PNG: ``/image`` sniffs the format from these bytes.
|
||||
_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
def _accept_images(state: ReplState) -> None:
|
||||
"""Mark the fixture's model as taking pictures (the config default is text-only)."""
|
||||
state.provider.models[state.model] = ModelConfig(image_in=True)
|
||||
|
||||
|
||||
class DummyClient:
|
||||
@overload
|
||||
async def chat_completions(
|
||||
@@ -469,8 +486,8 @@ async def test_yolo_rebuilds_tool_registry(tmp_path: Path) -> None:
|
||||
)
|
||||
try:
|
||||
assert st.agent.tools is not None
|
||||
# Soft-confirm hooks stay installed; YOLO mode does not bypass
|
||||
# dangerous invocations — internal danger classification takes precedence.
|
||||
# Soft-confirm hooks stay installed; YOLO mode only auto-approves the
|
||||
# confirms of YOLO-tagged tools, so the others still prompt.
|
||||
assert st.agent.tools.soft_confirm is True
|
||||
assert st.agent.tools.yolo is False
|
||||
st.set_yolo("on")
|
||||
@@ -543,7 +560,7 @@ async def test_repl_ctrl_c_from_turn_returns_to_prompt(
|
||||
raise EOFError
|
||||
return "hello"
|
||||
|
||||
async def interrupted_turn(_agent: object, _text: str) -> bool:
|
||||
async def interrupted_turn(_agent: object, _text: str, **_kwargs: object) -> bool:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
monkeypatch.setattr(repl_mod, "setup_readline", lambda _state: None)
|
||||
@@ -573,7 +590,7 @@ async def test_repl_second_ctrl_c_after_cancel_returns_to_prompt(
|
||||
raise EOFError
|
||||
return reads[calls["reads"] - 1]
|
||||
|
||||
async def cancel_turn(_agent: object, _text: str) -> bool:
|
||||
async def cancel_turn(_agent: object, _text: str, **_kwargs: object) -> bool:
|
||||
raise KeyboardInterrupt # first Ctrl+C: cancels every turn
|
||||
|
||||
def echo_user(text: str) -> None:
|
||||
@@ -780,3 +797,61 @@ async def test_todos_update_takes_a_listing_number(state: ReplState, capsys: pyt
|
||||
|
||||
assert await handle_slash(state, "/todos pop") is True
|
||||
assert "popped TOP group (first, second)" in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_a_mention_attaches_the_picture_to_the_turn(state: ReplState, tmp_path: Path) -> None:
|
||||
_accept_images(state)
|
||||
shot = tmp_path / "shot.png"
|
||||
_ = shot.write_bytes(_PNG)
|
||||
state.agent.stream = False
|
||||
|
||||
payload = resolve_mentions(state, f"look at @{shot}")
|
||||
assert payload.notes == []
|
||||
assert len(payload.images) == 1
|
||||
assert payload.text == f"look at @{shot}"
|
||||
|
||||
await _send_turn(state, payload.text)
|
||||
user = next(m for m in state.agent.messages if isinstance(m, UserChatMessage))
|
||||
assert isinstance(user.content, list)
|
||||
text_part, image_part = user.content
|
||||
assert isinstance(text_part, ChatTextPart)
|
||||
assert text_part.text == f"look at @{shot}"
|
||||
assert isinstance(image_part, ChatImagePart)
|
||||
assert image_part.image_url.url.startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
async def test_a_mention_inlines_a_text_file(state: ReplState, tmp_path: Path) -> None:
|
||||
notes = tmp_path / "notes.txt"
|
||||
_ = notes.write_text("the deploy needs acme", encoding="utf-8")
|
||||
|
||||
payload = resolve_mentions(state, f"read @{notes}")
|
||||
|
||||
assert payload.images == []
|
||||
assert payload.notes == []
|
||||
assert payload.text.startswith(f"read @{notes}")
|
||||
assert f"--- @{notes} (21 chars) ---" in payload.text
|
||||
assert payload.text.endswith("the deploy needs acme")
|
||||
|
||||
|
||||
async def test_a_mention_of_a_missing_file_is_a_note(state: ReplState, tmp_path: Path) -> None:
|
||||
payload = resolve_mentions(state, "@nope.png is gone")
|
||||
|
||||
assert payload.images == []
|
||||
assert payload.notes == ["@nope.png: file not found"]
|
||||
assert payload.text == "@nope.png is gone"
|
||||
|
||||
|
||||
async def test_a_picture_mention_is_skipped_for_a_text_only_model(
|
||||
state: ReplState,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""``image_in`` is the gate: a text-only model never receives a picture."""
|
||||
shot = tmp_path / "shot.png"
|
||||
_ = shot.write_bytes(_PNG)
|
||||
|
||||
payload = resolve_mentions(state, f"@{shot}")
|
||||
|
||||
assert payload.images == []
|
||||
note = payload.notes[0]
|
||||
assert "does not accept image input" in note
|
||||
assert "image_in = true" in note
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
@@ -44,7 +45,9 @@ def _skill_notice_rows(state: ReplState) -> list[str]:
|
||||
def _config_doc(tmp_path: Path, *, paths: list[Path] | None = None, extra: str = "") -> tomlkit.TOMLDocument:
|
||||
lines = ["[skills]", "discover = []"]
|
||||
if paths:
|
||||
rendered = ", ".join(f'"{path}"' for path in paths)
|
||||
# JSON string escaping is TOML escaping too, so a Windows path's
|
||||
# backslashes survive instead of reading as an escape (``\u``).
|
||||
rendered = ", ".join(json.dumps(str(path)) for path in paths)
|
||||
lines.append(f"paths = [{rendered}]")
|
||||
if extra:
|
||||
lines.append(extra)
|
||||
@@ -215,12 +218,12 @@ async def test_skill_tools_are_offered_and_can_read_skill_files(
|
||||
|
||||
skill_file = skills_state.skills.skills[0].skill_file if skills_state.skills else None
|
||||
assert skill_file is not None
|
||||
read = await skills_state.agent.tools.execute("read_file", f'{{"path": "{skill_file}"}}')
|
||||
read = await skills_state.agent.tools.execute("read_file", json.dumps({"path": str(skill_file)}))
|
||||
assert "Deploy the acme stack" in read
|
||||
|
||||
denied = await skills_state.agent.tools.execute(
|
||||
"write_file",
|
||||
f'{{"path": "{skill_file}", "content": "x"}}',
|
||||
json.dumps({"path": str(skill_file), "content": "x"}),
|
||||
)
|
||||
assert denied.startswith("error:")
|
||||
assert "escapes workspace root" in denied
|
||||
@@ -310,13 +313,13 @@ def test_prompt_skill_write_confirm_answers(monkeypatch: pytest.MonkeyPatch) ->
|
||||
|
||||
target = Path("/skills/acme")
|
||||
with temporary_backend(ScriptedBackend([])):
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "o\n")
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "o\n")
|
||||
once = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "s\n")
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "s\n")
|
||||
session = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "n\n")
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "n\n")
|
||||
denied = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: None)
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: None)
|
||||
timed_out = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||
assert (once.allow, once.session) == (True, False)
|
||||
assert (session.allow, session.session) == (True, True)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.config import load
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_notify_section_defaults(tmp_path: Path) -> None:
|
||||
"""Notifications are on by default: a five-second grace, desktop first."""
|
||||
path = tmp_path / "c.toml"
|
||||
_ = path.write_text("", encoding="utf-8")
|
||||
cfg = load(path).notify_config
|
||||
assert cfg.enabled is True
|
||||
assert cfg.grace_seconds == 5.0
|
||||
assert cfg.min_interval_seconds == 10.0
|
||||
assert cfg.channels == ["auto"]
|
||||
assert cfg.actions is True
|
||||
assert cfg.include_reason is False
|
||||
assert cfg.protocol == ""
|
||||
|
||||
|
||||
def test_notify_section_parse(tmp_path: Path) -> None:
|
||||
path = tmp_path / "c.toml"
|
||||
_ = path.write_text(
|
||||
"""
|
||||
[notify]
|
||||
enabled = false
|
||||
grace_seconds = 2.5
|
||||
min_interval_seconds = 30.0
|
||||
channels = ["terminal"]
|
||||
actions = false
|
||||
include_reason = true
|
||||
protocol = "osc9"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
cfg = load(path).notify_config
|
||||
assert cfg.enabled is False
|
||||
assert cfg.grace_seconds == 2.5
|
||||
assert cfg.min_interval_seconds == 30.0
|
||||
assert cfg.channels == ["terminal"]
|
||||
assert cfg.actions is False
|
||||
assert cfg.include_reason is True
|
||||
assert cfg.protocol == "osc9"
|
||||
|
||||
|
||||
def test_notify_section_typo_falls_back(tmp_path: Path) -> None:
|
||||
"""An unknown key or a bad type degrades to the defaults, like ``[mcp]``."""
|
||||
path = tmp_path / "c.toml"
|
||||
_ = path.write_text(
|
||||
"""
|
||||
[notify]
|
||||
grace = 1.0
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert load(path).notify_config.grace_seconds == 5.0
|
||||
|
||||
path2 = tmp_path / "d.toml"
|
||||
_ = path2.write_text(
|
||||
"""
|
||||
[notify]
|
||||
enabled = "yes"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert load(path2).notify_config.enabled is True
|
||||
|
||||
|
||||
def test_notify_section_survives_reload(tmp_path: Path) -> None:
|
||||
path = tmp_path / "c.toml"
|
||||
_ = path.write_text("[notify]\nenabled = false\n", encoding="utf-8")
|
||||
store = load(path)
|
||||
assert store.notify_config.enabled is False
|
||||
_ = path.write_text("[notify]\nenabled = true\ngrace_seconds = 1.0\n", encoding="utf-8")
|
||||
store.reload()
|
||||
assert store.notify_config.enabled is True
|
||||
assert store.notify_config.grace_seconds == 1.0
|
||||
@@ -40,6 +40,17 @@ allow_write = true
|
||||
assert cfg.allow_write is True
|
||||
|
||||
|
||||
def test_skills_section_survives_reload(tmp_path: Path) -> None:
|
||||
path = tmp_path / "c.toml"
|
||||
_ = path.write_text('[skills]\npaths = ["~/a"]\n', encoding="utf-8")
|
||||
store = load(path)
|
||||
assert store.skills_config.paths == ["~/a"]
|
||||
_ = path.write_text("[skills]\nenabled = false\npaths = []\n", encoding="utf-8")
|
||||
store.reload()
|
||||
assert store.skills_config.enabled is False
|
||||
assert store.skills_config.paths == []
|
||||
|
||||
|
||||
def test_skills_section_invalid_falls_back(tmp_path: Path) -> None:
|
||||
"""An unknown discovery source or a bad type degrades to the defaults."""
|
||||
path = tmp_path / "c.toml"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.config import load
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_trash_section_defaults(tmp_path: Path) -> None:
|
||||
"""Deletes go to the system trash unless the section says otherwise."""
|
||||
path = tmp_path / "c.toml"
|
||||
_ = path.write_text("", encoding="utf-8")
|
||||
assert load(path).trash_config.enabled is True
|
||||
|
||||
|
||||
def test_trash_section_parse(tmp_path: Path) -> None:
|
||||
path = tmp_path / "c.toml"
|
||||
_ = path.write_text("[trash]\nenabled = false\n", encoding="utf-8")
|
||||
assert load(path).trash_config.enabled is False
|
||||
|
||||
|
||||
def test_trash_section_typo_falls_back(tmp_path: Path) -> None:
|
||||
"""An unknown key or a bad type degrades to the defaults, like ``[mcp]``."""
|
||||
path = tmp_path / "c.toml"
|
||||
_ = path.write_text("[trash]\nenable = false\n", encoding="utf-8")
|
||||
assert load(path).trash_config.enabled is True
|
||||
|
||||
path2 = tmp_path / "d.toml"
|
||||
_ = path2.write_text('[trash]\nenabled = "no"\n', encoding="utf-8")
|
||||
assert load(path2).trash_config.enabled is True
|
||||
|
||||
|
||||
def test_trash_section_survives_reload(tmp_path: Path) -> None:
|
||||
path = tmp_path / "c.toml"
|
||||
_ = path.write_text("[trash]\nenabled = false\n", encoding="utf-8")
|
||||
store = load(path)
|
||||
assert store.trash_config.enabled is False
|
||||
_ = path.write_text("[trash]\nenabled = true\n", encoding="utf-8")
|
||||
store.reload()
|
||||
assert store.trash_config.enabled is True
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import msgspec
|
||||
from msgspec import UNSET
|
||||
@@ -18,10 +19,18 @@ from plyngent.lmproto.openai_compatible.compat import (
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AnyChatMessage,
|
||||
AssistantChatMessage,
|
||||
AssistantFunctionTool,
|
||||
AssistantFunctionToolCall,
|
||||
ChatCompletionsParam,
|
||||
ChatImagePart,
|
||||
ChatImageUrl,
|
||||
ChatTextPart,
|
||||
DeveloperChatMessage,
|
||||
ImageDetail,
|
||||
SystemChatMessage,
|
||||
ToolChatMessage,
|
||||
UserChatMessage,
|
||||
content_to_text,
|
||||
)
|
||||
|
||||
|
||||
@@ -119,3 +128,82 @@ def test_coerce_chat_messages_drops_provider_blocks() -> None:
|
||||
def test_coerce_chat_messages_leaves_plain_history_alone() -> None:
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi"), AssistantChatMessage(content="done")]
|
||||
assert coerce_chat_messages(messages) == messages
|
||||
|
||||
|
||||
def _image(url: str = "data:image/png;base64,AAAA", detail: ImageDetail = "low") -> ChatImagePart:
|
||||
return ChatImagePart(image_url=ChatImageUrl(url=url, detail=detail))
|
||||
|
||||
|
||||
def _wire(messages: Sequence[AnyChatMessage]) -> list[dict[str, Any]]:
|
||||
param = ChatCompletionsParam(model="m", messages=coerce_chat_messages(messages))
|
||||
raw: dict[str, Any] = msgspec.json.decode(msgspec.json.encode(param))
|
||||
messages_raw: list[dict[str, Any]] = raw["messages"]
|
||||
return messages_raw
|
||||
|
||||
|
||||
def test_user_images_reach_the_chat_wire_as_blocks() -> None:
|
||||
messages: list[AnyChatMessage] = [
|
||||
UserChatMessage(
|
||||
content=[
|
||||
ChatTextPart(text="what is this?"),
|
||||
ChatImagePart(image_url=ChatImageUrl(url="https://example.com/a.png", detail="low")),
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
assert _wire(messages) == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "what is this?"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/a.png", "detail": "low"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_tool_result_images_move_into_the_user_turn_after_the_tool_run() -> None:
|
||||
messages: list[AnyChatMessage] = [
|
||||
AssistantChatMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(
|
||||
id="call-1", function=AssistantFunctionTool(name="view_image", arguments="{}")
|
||||
)
|
||||
],
|
||||
),
|
||||
ToolChatMessage(content="image: shot.png (1 KB)", tool_call_id="call-1", images=[_image()]),
|
||||
ToolChatMessage(content="second result", tool_call_id="call-2"),
|
||||
AssistantChatMessage(content="I can see it."),
|
||||
]
|
||||
raw = _wire(messages)
|
||||
|
||||
assert [item["role"] for item in raw] == ["assistant", "tool", "tool", "user", "assistant"]
|
||||
# The tool message keeps its text and loses the field chat completions cannot send.
|
||||
assert raw[1] == {"role": "tool", "content": "image: shot.png (1 KB)", "tool_call_id": "call-1"}
|
||||
assert raw[3]["content"] == [
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA", "detail": "low"}}
|
||||
]
|
||||
# The stored history keeps its own image parts.
|
||||
assert messages[1].images is not UNSET
|
||||
|
||||
|
||||
def test_a_tool_result_without_images_keeps_the_history_untouched() -> None:
|
||||
messages: list[AnyChatMessage] = [ToolChatMessage(content="plain", tool_call_id="call-1")]
|
||||
assert coerce_chat_messages(messages) == messages
|
||||
|
||||
|
||||
def test_the_deepseek_param_takes_a_user_turn_with_images() -> None:
|
||||
base = DeepseekChatCompletionsParam(
|
||||
model="deepseek-flash",
|
||||
messages=[UserChatMessage(content=[_image()])],
|
||||
)
|
||||
coerced = coerce_chat_completions_param_any(base)
|
||||
raw = msgspec.json.decode(msgspec.json.encode(coerced))
|
||||
|
||||
assert raw["messages"][0]["content"][0]["type"] == "image_url"
|
||||
|
||||
|
||||
def test_content_to_text_replaces_images_with_a_marker() -> None:
|
||||
assert content_to_text("plain") == "plain"
|
||||
assert content_to_text([ChatTextPart(text="look:"), _image()]) == "look: [image]"
|
||||
|
||||
@@ -12,8 +12,10 @@ from plyngent.prompting import (
|
||||
NonInteractiveBackend,
|
||||
NonInteractiveError,
|
||||
PromptCancelledError,
|
||||
PromptWait,
|
||||
_read_line_with_timeout_posix,
|
||||
ask,
|
||||
ask_secret,
|
||||
cancel_prompt_read,
|
||||
choose,
|
||||
configure_prompting,
|
||||
@@ -21,12 +23,15 @@ from plyngent.prompting import (
|
||||
form,
|
||||
get_prompt_backend,
|
||||
get_prompt_output_hook,
|
||||
get_prompt_wait_hook,
|
||||
prompt_cancelled,
|
||||
prompt_wait,
|
||||
read_line_with_timeout,
|
||||
reset_prompting,
|
||||
run_cancellable_prompt_async,
|
||||
run_prompt_async,
|
||||
set_prompt_output_hook,
|
||||
set_prompt_wait_hook,
|
||||
temporary_backend,
|
||||
)
|
||||
|
||||
@@ -279,3 +284,133 @@ def test_prompt_output_hook_runs_before_chrome(capsys: pytest.CaptureFixture[str
|
||||
assert calls == ["break", "break"]
|
||||
assert get_prompt_output_hook() is None
|
||||
assert "box" in capsys.readouterr().out
|
||||
|
||||
|
||||
class _AnswerSource:
|
||||
"""Stand-in for a host surface that answers a waiting prompt."""
|
||||
|
||||
supports_actions = True
|
||||
|
||||
def __init__(self, answer: str | None) -> None:
|
||||
self.answers = [answer] if answer is not None else []
|
||||
|
||||
def take(self) -> str | None:
|
||||
return self.answers.pop(0) if self.answers else None
|
||||
|
||||
|
||||
def test_read_line_with_timeout_takes_source_answer(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A host answer ends the read even though stdin never becomes readable."""
|
||||
stream = io.StringIO()
|
||||
try:
|
||||
got = _read_line_with_timeout_posix("p> ", 5.0, stream=stream, poll=_never_poll, source=_AnswerSource("allow"))
|
||||
finally:
|
||||
stream.close()
|
||||
assert got == "allow"
|
||||
# The answer is echoed on the prompt line, so the terminal is not left mid-line.
|
||||
assert "allow" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_read_line_with_timeout_prefers_stdin_over_source() -> None:
|
||||
"""A human typing at the terminal wins over a pending host answer."""
|
||||
stream = io.StringIO("typed\n")
|
||||
try:
|
||||
got = _read_line_with_timeout_posix("p> ", 5.0, stream=stream, poll=_ready_poll, source=_AnswerSource("allow"))
|
||||
finally:
|
||||
stream.close()
|
||||
assert got == "typed"
|
||||
|
||||
|
||||
def test_read_line_with_timeout_without_deadline() -> None:
|
||||
"""``timeout=None`` waits until an answer arrives instead of giving up."""
|
||||
stream = io.StringIO()
|
||||
try:
|
||||
got = _read_line_with_timeout_posix("p> ", None, stream=stream, poll=_never_poll, source=_AnswerSource("deny"))
|
||||
finally:
|
||||
stream.close()
|
||||
assert got == "deny"
|
||||
|
||||
|
||||
def test_prompt_wait_without_hook_yields_none() -> None:
|
||||
"""Without a hook the seam is a no-op (library hosts keep the old behaviour)."""
|
||||
assert get_prompt_wait_hook() is None
|
||||
with prompt_wait("confirm", "Allow?") as source:
|
||||
assert source is None
|
||||
|
||||
|
||||
def test_prompt_wait_hook_wraps_ask() -> None:
|
||||
"""The CLI hook sees one PromptWait per prompt and is exited afterwards."""
|
||||
seen: list[PromptWait] = []
|
||||
entered: list[str] = []
|
||||
|
||||
@contextlib.contextmanager
|
||||
def hook(wait: PromptWait) -> Generator[None]:
|
||||
seen.append(wait)
|
||||
entered.append("in")
|
||||
try:
|
||||
yield None
|
||||
finally:
|
||||
entered.append("out")
|
||||
|
||||
set_prompt_wait_hook(hook)
|
||||
backend = ScriptedBackend(["hi"])
|
||||
with temporary_backend(backend):
|
||||
assert ask("Name?") == "hi"
|
||||
assert [(wait.kind, wait.title) for wait in seen] == [("ask", "Name?")]
|
||||
assert entered == ["in", "out"]
|
||||
|
||||
|
||||
def test_prompt_wait_hook_reports_the_prompt_family() -> None:
|
||||
"""Each entry point announces its own kind so a host can pick a surface."""
|
||||
kinds: list[str] = []
|
||||
|
||||
@contextlib.contextmanager
|
||||
def hook(wait: PromptWait) -> Generator[None]:
|
||||
kinds.append(wait.kind)
|
||||
yield None
|
||||
|
||||
set_prompt_wait_hook(hook)
|
||||
backend = ScriptedBackend(["hello"], confirms=[True], secrets=["s3cret"])
|
||||
with temporary_backend(backend):
|
||||
_ = ask_secret("Token?")
|
||||
_ = choose("Pick", ["a", "b"])
|
||||
_ = confirm("Go?")
|
||||
assert kinds == ["secret", "choose", "confirm"]
|
||||
|
||||
|
||||
def test_reset_prompting_clears_the_wait_hook() -> None:
|
||||
set_prompt_wait_hook(lambda wait: contextlib.nullcontext(None))
|
||||
assert get_prompt_wait_hook() is not None
|
||||
reset_prompting()
|
||||
assert get_prompt_wait_hook() is None
|
||||
|
||||
|
||||
def test_ask_takes_an_answer_from_the_notification() -> None:
|
||||
"""A reply typed into the notification is the answer (no readline involved)."""
|
||||
from plyngent.prompting import set_prompt_wait_hook
|
||||
|
||||
@contextlib.contextmanager
|
||||
def hook(wait: PromptWait) -> Generator[_AnswerSource]:
|
||||
del wait
|
||||
yield _AnswerSource("feature/x")
|
||||
|
||||
set_prompt_wait_hook(hook)
|
||||
backend = ScriptedBackend([]) # any readline would raise
|
||||
with temporary_backend(backend):
|
||||
assert ask("Which branch?") == "feature/x"
|
||||
|
||||
|
||||
def test_ask_ignores_a_source_that_offers_no_actions() -> None:
|
||||
"""A notify-only surface leaves the terminal read alone."""
|
||||
from plyngent.prompting import set_prompt_wait_hook
|
||||
|
||||
@contextlib.contextmanager
|
||||
def hook(wait: PromptWait) -> Generator[_AnswerSource]:
|
||||
del wait
|
||||
source = _AnswerSource("from the notification")
|
||||
source.supports_actions = False
|
||||
yield source
|
||||
|
||||
set_prompt_wait_hook(hook)
|
||||
backend = ScriptedBackend(["typed here"])
|
||||
with temporary_backend(backend):
|
||||
assert ask("Which branch?") == "typed here"
|
||||
|
||||
@@ -11,6 +11,8 @@ import os
|
||||
import sys
|
||||
|
||||
_FAKE_INSTRUCTIONS = os.environ.get("PLYNGENT_MCP_FAKE_INSTRUCTIONS", "")
|
||||
# A real 1x1 PNG (base64), what an image-carrying tool result looks like.
|
||||
_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
|
||||
|
||||
def respond(request_id: int, result: object) -> None:
|
||||
@@ -61,6 +63,11 @@ def main() -> None:
|
||||
"description": "Sleeps past any client timeout.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "shot",
|
||||
"description": "Returns a picture beside its text.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
@@ -75,6 +82,17 @@ def main() -> None:
|
||||
"isError": False,
|
||||
},
|
||||
)
|
||||
elif name == "shot":
|
||||
respond(
|
||||
int(request_id),
|
||||
{
|
||||
"content": [
|
||||
{"type": "text", "text": "shot.png"},
|
||||
{"type": "image", "data": _PNG_BASE64, "mimeType": "image/png"},
|
||||
],
|
||||
"isError": False,
|
||||
},
|
||||
)
|
||||
elif name == "fail":
|
||||
respond(
|
||||
int(request_id),
|
||||
|
||||
@@ -34,13 +34,28 @@ async def test_connection_handshake_lists_and_calls_tools() -> None:
|
||||
assert connection.connected
|
||||
assert connection.error is None
|
||||
assert connection.instructions is None
|
||||
assert [tool.name for tool in connection.tools] == ["echo", "fail", "slow"]
|
||||
assert [tool.name for tool in connection.tools] == ["echo", "fail", "slow", "shot"]
|
||||
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"
|
||||
assert result.text == "echo: hello"
|
||||
assert result.images == []
|
||||
finally:
|
||||
await connection.aclose()
|
||||
|
||||
|
||||
async def test_an_image_content_block_becomes_an_image_part() -> None:
|
||||
"""A server that returns pixels gets them to the model, not a placeholder."""
|
||||
connection = McpServerConnection("fake", _server_config())
|
||||
await connection.start()
|
||||
try:
|
||||
output = await connection.call_tool("shot", {})
|
||||
|
||||
assert output.text == "shot.png"
|
||||
assert len(output.images) == 1
|
||||
assert output.images[0].image_url.url.startswith("data:image/png;base64,")
|
||||
finally:
|
||||
await connection.aclose()
|
||||
|
||||
@@ -64,7 +79,7 @@ async def test_tool_error_returns_error_text() -> None:
|
||||
await connection.start()
|
||||
try:
|
||||
result = await connection.call_tool("fail", {})
|
||||
assert result == "error: boom"
|
||||
assert result.text == "error: boom"
|
||||
finally:
|
||||
await connection.aclose()
|
||||
|
||||
@@ -122,9 +137,9 @@ 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 [(name, status, count) for name, status, count in manager.statuses()] == [("fake", "connected", 4)]
|
||||
assert manager.instructions() == []
|
||||
assert await manager.call("fake", "echo", {"text": "hi"}) == "echo: hi"
|
||||
assert (await manager.call("fake", "echo", {"text": "hi"})).text == "echo: hi"
|
||||
finally:
|
||||
await manager.aclose()
|
||||
assert manager.statuses() == [("fake", "not started", 0)]
|
||||
@@ -157,5 +172,5 @@ async def test_manager_disable_and_restart() -> None:
|
||||
|
||||
await manager.restart(McpConfig(servers={"fake": _server_config()}, disable=[]))
|
||||
await manager.ensure_started()
|
||||
assert manager.statuses() == [("fake", "connected", 3)]
|
||||
assert manager.statuses() == [("fake", "connected", 4)]
|
||||
await manager.aclose()
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.config.models import SkillsConfig
|
||||
from plyngent.skills import SKILL_FILE, Skill, SkillStore, build_roots, validate_skill_name
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _write_skill(root: Path, name: str, *, description: str | None = "A test skill", body: str = "Body.") -> Path:
|
||||
directory = root / name
|
||||
@@ -28,12 +25,13 @@ def test_build_roots_orders_explicit_paths_first(tmp_path: Path) -> None:
|
||||
user_config_dir=tmp_path / "config",
|
||||
home=tmp_path / "home",
|
||||
)
|
||||
assert [(r.origin, r.scope, str(r.path)) for r in roots] == [
|
||||
("config", "user", str(tmp_path / "home" / "team-skills")),
|
||||
("config", "user", "/srv/skills"),
|
||||
("plyngent", "user", str(tmp_path / "config" / "skills")),
|
||||
("claude", "user", str(tmp_path / "home" / ".claude" / "skills")),
|
||||
("claude", "project", str(tmp_path / "proj" / ".claude" / "skills")),
|
||||
# Paths, not strings: a Windows run spells the same root differently.
|
||||
assert [(r.origin, r.scope, r.path) for r in roots] == [
|
||||
("config", "user", tmp_path / "home" / "team-skills"),
|
||||
("config", "user", Path("/srv/skills")),
|
||||
("plyngent", "user", tmp_path / "config" / "skills"),
|
||||
("claude", "user", tmp_path / "home" / ".claude" / "skills"),
|
||||
("claude", "project", tmp_path / "proj" / ".claude" / "skills"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ def test_read_only_classification() -> None:
|
||||
read_only = {t.name for t in default_tool_definitions(surface="local") if t.tags & ToolTag.READ_ONLY}
|
||||
assert read_only == {
|
||||
"read_file",
|
||||
"view_image",
|
||||
"get_truncated",
|
||||
"glob_paths",
|
||||
"regex_files",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from plyngent.tools.command_scan import basename, is_interpreter, unwrap_command
|
||||
from plyngent.tools.command_scan import basename, escalators_in_chain, is_interpreter, unwrap_command
|
||||
|
||||
|
||||
def test_basename() -> None:
|
||||
@@ -9,6 +9,15 @@ def test_basename() -> None:
|
||||
assert basename("pdm") == "pdm"
|
||||
|
||||
|
||||
def test_escalators_in_chain() -> None:
|
||||
assert escalators_in_chain(["sudo", "-u", "root", "python", "-c", "print(1)"]) == ("sudo",)
|
||||
assert escalators_in_chain(["env", "FOO=1", "doas", "id"]) == ("doas",)
|
||||
assert escalators_in_chain(["pdm", "run", "sudo", "id"]) == ("sudo",)
|
||||
# Only the command line counts: the code an interpreter carries is not read.
|
||||
assert escalators_in_chain(["bash", "-c", "sudo id"]) == ()
|
||||
assert escalators_in_chain(["grep", "-c", "sudo", "notes.txt"]) == ()
|
||||
|
||||
|
||||
def test_unwrap_plain_command() -> None:
|
||||
scan = unwrap_command(["grep", "-c", "needle", "f.txt"])
|
||||
assert scan.wrappers == ()
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from plyngent.agent import ToolRegistry, ToolTag, tool
|
||||
from plyngent.agent import DangerReason, ToolRegistry, ToolTag, tool
|
||||
from plyngent.tools.context import SessionState, bind_tool_context
|
||||
from plyngent.tools.grants import clear_grants, has_grant, hydrate_grants, persist_grants
|
||||
from plyngent.tools.view import MemoryViewStore, session_data_view
|
||||
|
||||
|
||||
async def test_danger_overrides_yolo_tag() -> None:
|
||||
"""Decorator YOLO tag does not bypass confirm when danger flags the call."""
|
||||
async def test_yolo_only_skips_yolo_tagged_tools() -> None:
|
||||
"""YOLO mode auto-approves the soft confirms of YOLO-tagged tools only."""
|
||||
calls: list[str] = []
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
|
||||
async def yolo_tool() -> str:
|
||||
async def yolo_ok() -> str:
|
||||
return "y"
|
||||
|
||||
@tool(tags=ToolTag.LOCAL, register=False)
|
||||
@@ -28,15 +28,55 @@ async def test_danger_overrides_yolo_tag() -> None:
|
||||
return True
|
||||
|
||||
reg = ToolRegistry(
|
||||
[yolo_tool, no_yolo],
|
||||
[yolo_ok, no_yolo],
|
||||
danger=danger,
|
||||
on_confirm=confirm,
|
||||
yolo=True,
|
||||
)
|
||||
assert await reg.execute("yolo_tool", "{}") == "y"
|
||||
assert calls == ["yolo_tool"]
|
||||
assert await reg.execute("yolo_ok", "{}") == "y"
|
||||
assert calls == []
|
||||
assert await reg.execute("no_yolo", "{}") == "n"
|
||||
assert calls == ["yolo_tool", "no_yolo"]
|
||||
assert calls == ["no_yolo"]
|
||||
|
||||
|
||||
async def test_yolo_off_keeps_the_yolo_tagged_prompt() -> None:
|
||||
"""The YOLO tag is a policy bit: with YOLO off the call still asks."""
|
||||
calls: list[str] = []
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
|
||||
async def yolo_ok() -> str:
|
||||
return "y"
|
||||
|
||||
def danger(_name: str, _args: object) -> str | None:
|
||||
return "soft reason"
|
||||
|
||||
async def confirm(name: str, _args: object, _reason: str) -> bool:
|
||||
calls.append(name)
|
||||
return True
|
||||
|
||||
reg = ToolRegistry([yolo_ok], danger=danger, on_confirm=confirm, yolo=False)
|
||||
assert await reg.execute("yolo_ok", "{}") == "y"
|
||||
assert calls == ["yolo_ok"]
|
||||
|
||||
|
||||
async def test_yolo_keeps_a_reason_that_refuses_the_skip() -> None:
|
||||
"""`DangerReason(yolo_skip=False)` — a privilege escalation — still prompts."""
|
||||
seen: list[str] = []
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
|
||||
async def escalated() -> str:
|
||||
return "x"
|
||||
|
||||
def danger(_name: str, _args: object) -> DangerReason:
|
||||
return DangerReason("interpreter 'python' + privilege escalation sudo", yolo_skip=False)
|
||||
|
||||
async def confirm(_name: str, _args: object, reason: str) -> bool:
|
||||
seen.append(reason)
|
||||
return True
|
||||
|
||||
reg = ToolRegistry([escalated], danger=danger, on_confirm=confirm, yolo=True)
|
||||
assert await reg.execute("escalated", "{}") == "x"
|
||||
assert seen == ["interpreter 'python' + privilege escalation sudo"]
|
||||
|
||||
|
||||
async def test_trustable_grant_once() -> None:
|
||||
|
||||
+107
-45
@@ -2,19 +2,53 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.agent import DangerReason
|
||||
from plyngent.tools.danger import classify_danger
|
||||
from plyngent.tools.workspace import set_trash_enabled
|
||||
|
||||
|
||||
def _reason(name: str, args: dict[str, object]) -> str | None:
|
||||
"""Confirm text of a classifier answer (``None`` when the call needs none)."""
|
||||
found = classify_danger(name, args)
|
||||
if found is None:
|
||||
return None
|
||||
return found if isinstance(found, str) else found.text
|
||||
|
||||
|
||||
def _danger(name: str, args: dict[str, object]) -> DangerReason:
|
||||
"""Classifier answer as a typed reason (the call must need a confirm)."""
|
||||
found = classify_danger(name, args)
|
||||
assert isinstance(found, DangerReason), found
|
||||
return found
|
||||
|
||||
|
||||
def test_classify_delete_and_move() -> None:
|
||||
assert classify_danger("delete_path", {"path": "a.txt"}) == "delete path 'a.txt'"
|
||||
assert "recursively" in (classify_danger("delete_path", {"path": "d", "recursive": True}) or "")
|
||||
assert "move" in (classify_danger("move_path", {"src": "a", "dst": "b"}) or "")
|
||||
# No bound workspace here, so the confirm falls back to the default wording.
|
||||
assert _reason("delete_path", {"path": "a.txt"}) == "move path 'a.txt' to the system trash"
|
||||
assert "recursively" in (_reason("delete_path", {"path": "d", "recursive": True}) or "")
|
||||
assert "move" in (_reason("move_path", {"src": "a", "dst": "b"}) or "")
|
||||
|
||||
|
||||
def test_classify_permanent_delete() -> None:
|
||||
assert _reason("delete_path", {"path": "a.txt", "permanent": True}) == "delete path 'a.txt' permanently"
|
||||
|
||||
|
||||
def test_a_scratch_path_is_announced_as_permanent(workspace: object) -> None:
|
||||
"""The confirm says what will happen: the temp workspace is never trashed."""
|
||||
del workspace
|
||||
assert _reason("delete_path", {"path": "a.txt"}) == "delete path 'a.txt' permanently"
|
||||
|
||||
|
||||
def test_the_host_switch_is_announced_as_permanent(workspace: object) -> None:
|
||||
del workspace
|
||||
set_trash_enabled(enabled=False)
|
||||
assert _reason("delete_path", {"path": "a.txt"}) == "delete path 'a.txt' permanently"
|
||||
|
||||
|
||||
def test_classify_copy() -> None:
|
||||
# Without overwrite flag, copy is not soft-confirmed.
|
||||
assert classify_danger("copy_path", {"src": "a", "dst": "b"}) is None
|
||||
assert classify_danger("copy_path", {"src": "a", "dst": "b", "overwrite": False}) is None
|
||||
assert _reason("copy_path", {"src": "a", "dst": "b"}) is None
|
||||
assert _reason("copy_path", {"src": "a", "dst": "b", "overwrite": False}) is None
|
||||
|
||||
|
||||
def test_classify_write_overwrite_only(tmp_path: Path) -> None:
|
||||
@@ -25,35 +59,35 @@ def test_classify_write_overwrite_only(tmp_path: Path) -> None:
|
||||
with bind_instance(instance):
|
||||
_ = set_workspace_root(tmp_path)
|
||||
# New file: no soft-confirm, with or without the overwrite flag
|
||||
assert classify_danger("write_file", {"path": "new.txt"}) is None
|
||||
assert classify_danger("write_file", {"path": "new.txt", "overwrite": True}) is None
|
||||
assert _reason("write_file", {"path": "new.txt"}) is None
|
||||
assert _reason("write_file", {"path": "new.txt", "overwrite": True}) is None
|
||||
# Partial edits: never soft-confirm
|
||||
assert classify_danger("edit_replace", {"path": "x.txt"}) is None
|
||||
assert classify_danger("edit_lineno", {"path": "x.txt", "start_line": 1, "end_line": 2}) is None
|
||||
assert _reason("edit_replace", {"path": "x.txt"}) is None
|
||||
assert _reason("edit_lineno", {"path": "x.txt", "start_line": 1, "end_line": 2}) is None
|
||||
# Existing file: only an explicit overwrite asks; an unset/false flag is
|
||||
# refused inside the tool, so no prompt may fire for it.
|
||||
(tmp_path / "x.txt").write_text("old", encoding="utf-8")
|
||||
assert classify_danger("write_file", {"path": "x.txt"}) is None
|
||||
assert classify_danger("write_file", {"path": "x.txt", "overwrite": False}) is None
|
||||
reason = classify_danger("write_file", {"path": "x.txt", "overwrite": True})
|
||||
assert _reason("write_file", {"path": "x.txt"}) is None
|
||||
assert _reason("write_file", {"path": "x.txt", "overwrite": False}) is None
|
||||
reason = _reason("write_file", {"path": "x.txt", "overwrite": True})
|
||||
assert reason is not None and "overwrite" in reason
|
||||
(tmp_path / "dst.txt").write_text("d", encoding="utf-8")
|
||||
(tmp_path / "src.txt").write_text("s", encoding="utf-8")
|
||||
assert classify_danger("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": False}) is None
|
||||
creason = classify_danger("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": True})
|
||||
assert _reason("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": False}) is None
|
||||
creason = _reason("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": True})
|
||||
assert creason is not None and "overwrite" in creason
|
||||
|
||||
|
||||
def test_classify_safe_tools() -> None:
|
||||
assert classify_danger("read_file", {"path": "a"}) is None
|
||||
assert classify_danger("listdir", {"path": "."}) is None
|
||||
assert classify_danger("run_argv", {"argv": ["echo", "hi"]}) is None
|
||||
assert classify_danger("open_pty", {"command": ["true"]}) is None
|
||||
assert classify_danger("run_argv", {"argv": ["ls", "-la"]}) is None
|
||||
assert _reason("read_file", {"path": "a"}) is None
|
||||
assert _reason("listdir", {"path": "."}) is None
|
||||
assert _reason("run_argv", {"argv": ["echo", "hi"]}) is None
|
||||
assert _reason("open_pty", {"command": ["true"]}) is None
|
||||
assert _reason("run_argv", {"argv": ["ls", "-la"]}) is None
|
||||
|
||||
|
||||
def test_classify_run_argv_batch_risky() -> None:
|
||||
reason = classify_danger(
|
||||
reason = _reason(
|
||||
"run_argv_batch",
|
||||
{
|
||||
"steps": [
|
||||
@@ -66,7 +100,7 @@ def test_classify_run_argv_batch_risky() -> None:
|
||||
assert "run_argv_batch" in reason
|
||||
assert "risky" in reason or "bash" in reason
|
||||
assert (
|
||||
classify_danger(
|
||||
_reason(
|
||||
"run_argv_batch",
|
||||
{"steps": [{"argv": ["echo", "ok"]}]},
|
||||
)
|
||||
@@ -75,42 +109,42 @@ def test_classify_run_argv_batch_risky() -> None:
|
||||
|
||||
|
||||
def test_classify_shell_and_dash_c() -> None:
|
||||
r = classify_danger("run_argv", {"argv": ["bash", "-c", "rm -rf /"]})
|
||||
r = _reason("run_argv", {"argv": ["bash", "-c", "rm -rf /"]})
|
||||
assert r is not None
|
||||
assert "bash -c" in r
|
||||
assert "rm -rf" in r
|
||||
|
||||
r2 = classify_danger("run_argv", {"argv": ["python3", "-c", "print(1)"]})
|
||||
r2 = _reason("run_argv", {"argv": ["python3", "-c", "print(1)"]})
|
||||
assert r2 is not None
|
||||
assert "python" in r2
|
||||
assert "-c" in r2
|
||||
assert "print(1)" in r2
|
||||
|
||||
r_py = classify_danger("run_argv", {"argv": ["python", "-c", "import os"]})
|
||||
r_py = _reason("run_argv", {"argv": ["python", "-c", "import os"]})
|
||||
assert r_py is not None and "python -c" in r_py
|
||||
|
||||
r3 = classify_danger("open_pty", {"command": ["bash"]})
|
||||
r3 = _reason("open_pty", {"command": ["bash"]})
|
||||
assert r3 is not None and "bash" in r3
|
||||
assert "interactive" in r3 or "review" in r3
|
||||
|
||||
r4 = classify_danger("open_pty", {"command": ["python3"]})
|
||||
r4 = _reason("open_pty", {"command": ["python3"]})
|
||||
assert r4 is not None and "python" in r4
|
||||
|
||||
r5 = classify_danger("open_pty", {"command": ["python3", "-c", "x=1"]})
|
||||
r5 = _reason("open_pty", {"command": ["python3", "-c", "x=1"]})
|
||||
assert r5 is not None and "-c" in r5
|
||||
|
||||
|
||||
def test_dash_c_means_code_only_for_interpreters() -> None:
|
||||
# ``-c`` is a plain flag elsewhere: no code to review, no confirm.
|
||||
assert classify_danger("run_argv", {"argv": ["grep", "-c", "needle", "f.txt"]}) is None
|
||||
assert classify_danger("run_argv", {"argv": ["od", "-c", "f.bin"]}) is None
|
||||
assert classify_danger("run_argv", {"argv": ["sort", "-c", "f.txt"]}) is None
|
||||
assert _reason("run_argv", {"argv": ["grep", "-c", "needle", "f.txt"]}) is None
|
||||
assert _reason("run_argv", {"argv": ["od", "-c", "f.bin"]}) is None
|
||||
assert _reason("run_argv", {"argv": ["sort", "-c", "f.txt"]}) is None
|
||||
|
||||
|
||||
def test_interpreter_review_does_not_split_on_dash_c() -> None:
|
||||
script = classify_danger("run_argv", {"argv": ["python", "script.py"]})
|
||||
one_liner = classify_danger("run_argv", {"argv": ["python", "-c", "print(1)"]})
|
||||
bare_shell = classify_danger("open_pty", {"command": ["bash"]})
|
||||
script = _reason("run_argv", {"argv": ["python", "script.py"]})
|
||||
one_liner = _reason("run_argv", {"argv": ["python", "-c", "print(1)"]})
|
||||
bare_shell = _reason("open_pty", {"command": ["bash"]})
|
||||
assert script is not None and "interpreter 'python'" in script
|
||||
assert one_liner is not None and "interpreter 'python'" in one_liner
|
||||
assert bare_shell is not None and "interpreter 'bash'" in bare_shell
|
||||
@@ -121,7 +155,7 @@ def test_interpreter_review_does_not_split_on_dash_c() -> None:
|
||||
|
||||
|
||||
def test_versioned_interpreter_is_still_reviewed() -> None:
|
||||
reason = classify_danger("run_argv", {"argv": ["python3.12", "-c", "import os"]})
|
||||
reason = _reason("run_argv", {"argv": ["python3.12", "-c", "import os"]})
|
||||
assert reason is not None and "interpreter 'python3.12'" in reason
|
||||
assert "import os" in reason
|
||||
|
||||
@@ -134,7 +168,7 @@ def test_wrappers_reach_the_interpreter() -> None:
|
||||
["pdm", "run", "python", "-c", "print(1)"],
|
||||
["uv", "run", "--with", "rich", "python", "-c", "print(1)"],
|
||||
):
|
||||
reason = classify_danger("run_argv", {"argv": argv})
|
||||
reason = _reason("run_argv", {"argv": argv})
|
||||
assert reason is not None, argv
|
||||
assert "interpreter 'python'" in reason or "interpreter 'bash'" in reason
|
||||
assert "$(command)" in reason
|
||||
@@ -142,7 +176,7 @@ def test_wrappers_reach_the_interpreter() -> None:
|
||||
|
||||
def test_value_free_flags_do_not_hide_the_interpreter() -> None:
|
||||
"""``exec -c`` takes no value, so the interpreter behind it is still reviewed."""
|
||||
reason = classify_danger("run_argv", {"argv": ["exec", "-c", "python", "-c", "print(1)"]})
|
||||
reason = _reason("run_argv", {"argv": ["exec", "-c", "python", "-c", "print(1)"]})
|
||||
assert reason is not None
|
||||
assert "interpreter 'python'" in reason
|
||||
assert "print(1)" in reason
|
||||
@@ -150,26 +184,26 @@ def test_value_free_flags_do_not_hide_the_interpreter() -> None:
|
||||
|
||||
def test_wrapper_command_line_option_is_reviewed() -> None:
|
||||
"""`env -S` / `su -c` carry a command line, so they are confirmed like `-c`."""
|
||||
reason = classify_danger("run_argv", {"argv": ["env", "-S", "rm -rf /"]})
|
||||
reason = _reason("run_argv", {"argv": ["env", "-S", "rm -rf /"]})
|
||||
assert reason is not None
|
||||
assert "env -S runs a command line" in reason
|
||||
assert " env -S:\n rm -rf /" in reason
|
||||
|
||||
# Plain wrapper options are not a review trigger.
|
||||
assert classify_danger("run_argv", {"argv": ["env", "-u", "FOO=1", "git", "status"]}) is None
|
||||
assert classify_danger("run_argv", {"argv": ["env", "FOO=1", "git", "-c", "x=y", "log"]}) is None
|
||||
assert _reason("run_argv", {"argv": ["env", "-u", "FOO=1", "git", "status"]}) is None
|
||||
assert _reason("run_argv", {"argv": ["env", "FOO=1", "git", "-c", "x=y", "log"]}) is None
|
||||
|
||||
|
||||
def test_wrapper_command_line_value_needs_no_interpreter() -> None:
|
||||
"""The wrapper's value is reviewed even when no interpreter is resolved."""
|
||||
reason = classify_danger("run_argv", {"argv": ["env", "-S", "python -O script.py"]})
|
||||
reason = _reason("run_argv", {"argv": ["env", "-S", "python -O script.py"]})
|
||||
assert reason is not None
|
||||
assert "env -S runs a command line" in reason
|
||||
assert "python -O script.py" in reason
|
||||
|
||||
|
||||
def test_only_the_interpreter_dash_c_becomes_a_placeholder() -> None:
|
||||
reason = classify_danger("run_argv", {"argv": ["ionice", "-c", "2", "python", "-c", "print(1)"]})
|
||||
reason = _reason("run_argv", {"argv": ["ionice", "-c", "2", "python", "-c", "print(1)"]})
|
||||
assert reason is not None
|
||||
argv_line = next(line for line in reason.splitlines() if line.startswith(" argv:"))
|
||||
assert "ionice -c 2" in argv_line
|
||||
@@ -178,15 +212,43 @@ def test_only_the_interpreter_dash_c_becomes_a_placeholder() -> None:
|
||||
|
||||
|
||||
def test_detached_runner_is_always_reviewed() -> None:
|
||||
assert classify_danger("run_argv", {"argv": ["sleep", "5"]}) is None
|
||||
reason = classify_danger("run_argv", {"argv": ["nohup", "sleep", "5"]})
|
||||
assert _reason("run_argv", {"argv": ["sleep", "5"]}) is None
|
||||
reason = _reason("run_argv", {"argv": ["nohup", "sleep", "5"]})
|
||||
assert reason is not None and "nohup (detached run)" in reason
|
||||
nested = classify_danger("run_argv", {"argv": ["nohup", "python", "-c", "print(1)"]})
|
||||
nested = _reason("run_argv", {"argv": ["nohup", "python", "-c", "print(1)"]})
|
||||
assert nested is not None
|
||||
assert "nohup (detached run)" in nested and "interpreter 'python'" in nested
|
||||
assert "print(1)" in nested
|
||||
|
||||
|
||||
def test_privilege_escalation_refuses_the_yolo_skip() -> None:
|
||||
"""A reason that runs through `sudo`/`doas` keeps its prompt under YOLO mode."""
|
||||
reason = _danger("run_argv", {"argv": ["sudo", "python", "-c", "print(1)"]})
|
||||
assert "interpreter 'python'" in reason.text
|
||||
assert "privilege escalation sudo" in reason.text
|
||||
assert reason.yolo_skip is False
|
||||
|
||||
assert _danger("run_argv", {"argv": ["env", "FOO=1", "doas", "bash", "-c", "ls"]}).yolo_skip is False
|
||||
# A plain interpreter run is still YOLO mode's to drop.
|
||||
assert _danger("run_argv", {"argv": ["bash", "-c", "echo hi"]}).yolo_skip is True
|
||||
# Escalation adds no reason of its own: the denylist gate asks about it.
|
||||
assert classify_danger("run_argv", {"argv": ["sudo", "ls"]}) is None
|
||||
|
||||
|
||||
def test_batch_refuses_the_yolo_skip_when_a_step_escalates() -> None:
|
||||
"""One escalating step keeps the batch prompt; all-plain steps may be dropped."""
|
||||
mixed = _danger(
|
||||
"run_argv_batch",
|
||||
{"steps": [{"argv": ["bash", "-c", "ls"]}, {"argv": ["sudo", "python", "-c", "print(1)"]}]},
|
||||
)
|
||||
assert mixed.yolo_skip is False
|
||||
plain = _danger(
|
||||
"run_argv_batch",
|
||||
{"steps": [{"argv": ["bash", "-c", "ls"]}, {"argv": ["python", "-c", "print(1)"]}]},
|
||||
)
|
||||
assert plain.yolo_skip is True
|
||||
|
||||
|
||||
async def test_confirm_deny_with_comment() -> None:
|
||||
from plyngent.agent.tools import ToolRegistry, tool
|
||||
from plyngent.tools.danger import classify_danger as danger
|
||||
@@ -208,7 +270,7 @@ async def test_confirm_deny_with_comment() -> None:
|
||||
|
||||
def test_shell_confirm_formats_command_placeholder() -> None:
|
||||
script = chr(10).join(["line1", "line2", "line3"])
|
||||
reason = classify_danger(
|
||||
reason = _reason(
|
||||
"run_argv",
|
||||
{"argv": ["bash", "-c", script]},
|
||||
)
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.agent import ToolOutput
|
||||
from plyngent.tools.context import read_only_context
|
||||
from plyngent.tools.danger import classify_danger
|
||||
from plyngent.tools.net import fetch_local, grant_private_host
|
||||
@@ -29,6 +31,12 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# A real 1x1 PNG: the body must be sniffed by its bytes, not by the header.
|
||||
_PNG_BYTES = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
"""Minimal echo server for fetch_local tests."""
|
||||
|
||||
@@ -66,6 +74,9 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
if self.path.startswith("/bin"):
|
||||
self._send(200, b"\x00\x01\x02\xffbinary", content_type="application/octet-stream")
|
||||
return
|
||||
if self.path.startswith("/png"):
|
||||
self._send(200, _PNG_BYTES, content_type="image/png")
|
||||
return
|
||||
if self.path.startswith("/ua"):
|
||||
ua = (self.headers.get("User-Agent") or "").encode()
|
||||
self._send(200, ua)
|
||||
@@ -173,9 +184,9 @@ def test_soft_confirm_reason_matrix() -> None:
|
||||
def test_classify_danger_fetch() -> None:
|
||||
assert classify_danger("fetch_local", {"url": "https://example.com/", "method": "GET"}) is None
|
||||
reason = classify_danger("fetch_local", {"url": "http://example.com/", "method": "GET"})
|
||||
assert reason is not None and "cleartext" in reason
|
||||
assert isinstance(reason, str) and "cleartext" in reason
|
||||
reason2 = classify_danger("fetch_local", {"url": "https://example.com/api", "method": "DELETE"})
|
||||
assert reason2 is not None and "DELETE" in reason2
|
||||
assert isinstance(reason2, str) and "DELETE" in reason2
|
||||
|
||||
|
||||
async def test_fetch_get_post_put_delete(workspace: Path, http_server: str) -> None:
|
||||
@@ -200,6 +211,20 @@ async def test_fetch_get_post_put_delete(workspace: Path, http_server: str) -> N
|
||||
assert "status=204" in out_del
|
||||
|
||||
|
||||
async def test_an_image_response_comes_back_as_the_picture(workspace: Path, http_server: str) -> None:
|
||||
"""A PNG body is handed to the model as an image part, not a hex preview."""
|
||||
del workspace
|
||||
grant_private_host("127.0.0.1", int(http_server.rsplit(":", 1)[1]))
|
||||
|
||||
output: object = await fetch_local.handler(f"{http_server}/png")
|
||||
|
||||
assert isinstance(output, ToolOutput)
|
||||
assert "body_kind=image" in output.text
|
||||
assert "status=200" in output.text
|
||||
part = output.images[0]
|
||||
assert part.image_url.url.startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
async def test_fetch_read_only_context_blocks_non_get(workspace: Path, http_server: str) -> None:
|
||||
"""In a read-only context fetch_local only allows GET; outside it POST works."""
|
||||
del workspace
|
||||
|
||||
@@ -2,7 +2,10 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.tools.file import copy_path, delete_path, move_path, read_file, write_file
|
||||
import pytest
|
||||
|
||||
from plyngent.tools.file import copy_path, delete_path, fs_ops, move_path, read_file, write_file
|
||||
from plyngent.tools.workspace import set_trash_enabled
|
||||
from tests.test_tools.helpers import call_sync
|
||||
|
||||
|
||||
@@ -75,6 +78,8 @@ def test_delete_nonempty_requires_recursive(workspace: object) -> None:
|
||||
out = call_sync(delete_path, "box")
|
||||
assert "not empty" in out
|
||||
assert (workspace / "box" / "a.txt").is_file()
|
||||
# The workspace fixture lives under the system temp dir, so it is removed
|
||||
# for good rather than trashed (see the trash tests below).
|
||||
out2 = call_sync(delete_path, "box", recursive=True)
|
||||
assert "recursive" in out2
|
||||
assert not (workspace / "box").exists()
|
||||
@@ -97,3 +102,84 @@ def test_missing_source(workspace: object) -> None:
|
||||
assert "does not exist" in call_sync(copy_path, "nope", "x")
|
||||
assert "does not exist" in call_sync(move_path, "nope", "x")
|
||||
assert "does not exist" in call_sync(delete_path, "nope")
|
||||
|
||||
|
||||
def _elsewhere_than_temp(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Pretend the workspace is not scratch space, so deletes use the trash."""
|
||||
monkeypatch.setattr(fs_ops, "under_system_temp", lambda _path: False)
|
||||
|
||||
|
||||
def test_a_temp_path_is_deleted_for_good(workspace: object) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
_ = call_sync(write_file, "t.txt", "x")
|
||||
out = call_sync(delete_path, "t.txt")
|
||||
assert "trash" not in out
|
||||
assert not (workspace / "t.txt").exists()
|
||||
|
||||
|
||||
def test_delete_goes_to_the_trash(workspace: object, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
_elsewhere_than_temp(monkeypatch)
|
||||
trashed: list[Path] = []
|
||||
monkeypatch.setattr(fs_ops, "move_to_trash", trashed.append)
|
||||
_ = call_sync(write_file, "t.txt", "x")
|
||||
|
||||
out = call_sync(delete_path, "t.txt")
|
||||
|
||||
assert out == "deleted file t.txt (moved to system trash)"
|
||||
assert [path.name for path in trashed] == ["t.txt"]
|
||||
|
||||
|
||||
def test_recursive_delete_goes_to_the_trash(workspace: object, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
_elsewhere_than_temp(monkeypatch)
|
||||
trashed: list[Path] = []
|
||||
monkeypatch.setattr(fs_ops, "move_to_trash", trashed.append)
|
||||
_ = call_sync(write_file, "box/a.txt", "a")
|
||||
|
||||
out = call_sync(delete_path, "box", recursive=True)
|
||||
|
||||
assert out == "deleted directory box (moved to system trash)"
|
||||
assert [path.name for path in trashed] == ["box"]
|
||||
|
||||
|
||||
def test_a_refused_trash_keeps_the_path(workspace: object, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
_elsewhere_than_temp(monkeypatch)
|
||||
refused = "error: cannot move to trash: nope (use permanent=true)"
|
||||
monkeypatch.setattr(fs_ops, "move_to_trash", lambda _target: refused)
|
||||
_ = call_sync(write_file, "t.txt", "x")
|
||||
|
||||
out = call_sync(delete_path, "t.txt")
|
||||
|
||||
assert out == refused
|
||||
assert (workspace / "t.txt").is_file()
|
||||
|
||||
|
||||
def test_permanent_delete_skips_the_trash(workspace: object, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert isinstance(workspace, Path)
|
||||
|
||||
def _never(_target: Path) -> str | None:
|
||||
msg = "a permanent delete must not touch the trash"
|
||||
raise AssertionError(msg)
|
||||
|
||||
monkeypatch.setattr(fs_ops, "move_to_trash", _never)
|
||||
_ = call_sync(write_file, "t.txt", "x")
|
||||
|
||||
out = call_sync(delete_path, "t.txt", permanent=True)
|
||||
|
||||
assert out == "deleted file t.txt"
|
||||
assert not (workspace / "t.txt").exists()
|
||||
|
||||
|
||||
def test_the_host_switch_turns_the_trash_off(workspace: object, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``[trash] enabled = false`` restores the behaviour from before the trash."""
|
||||
assert isinstance(workspace, Path)
|
||||
_elsewhere_than_temp(monkeypatch)
|
||||
set_trash_enabled(enabled=False)
|
||||
_ = call_sync(write_file, "t.txt", "x")
|
||||
|
||||
out = call_sync(delete_path, "t.txt")
|
||||
|
||||
assert out == "deleted file t.txt"
|
||||
assert not (workspace / "t.txt").exists()
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import send2trash
|
||||
|
||||
from plyngent.tools import trash
|
||||
from plyngent.tools.trash import is_available, move_to_trash, under_system_temp
|
||||
|
||||
# The freedesktop implementation is the only one whose trash location can be
|
||||
# redirected for a test; macOS and Windows move into their own system trash.
|
||||
freedesktop_only = pytest.mark.skipif(sys.platform != "linux", reason="freedesktop trash only")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def home_trash(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Point the freedesktop implementation at a trash under *tmp_path*.
|
||||
|
||||
``plat_other`` reads its trash locations from module globals computed at
|
||||
import time and picks "home" vs "external volume" by device number, so both
|
||||
are pinned here — otherwise a ``/tmp`` on a filesystem of its own would
|
||||
create a real trash directory on that volume.
|
||||
"""
|
||||
plat_other = pytest.importorskip("send2trash.plat_other")
|
||||
root = tmp_path / "Trash"
|
||||
monkeypatch.setattr(plat_other, "XDG_DATA_HOME", os.fsencode(str(tmp_path)))
|
||||
monkeypatch.setattr(plat_other, "HOMETRASH_B", os.fsencode(str(root)))
|
||||
monkeypatch.setattr(plat_other, "HOMETRASH", str(root))
|
||||
monkeypatch.setattr(plat_other, "get_dev", lambda _path: 1)
|
||||
# A GIO-backed install would otherwise bypass every patch above.
|
||||
monkeypatch.setattr(send2trash, "send2trash", plat_other.send2trash)
|
||||
return root
|
||||
|
||||
|
||||
def test_the_library_is_installed() -> None:
|
||||
assert is_available()
|
||||
|
||||
|
||||
@freedesktop_only
|
||||
def test_move_to_trash_lands_in_the_trash(home_trash: Path, tmp_path: Path) -> None:
|
||||
target = tmp_path / "gone.txt"
|
||||
_ = target.write_text("x")
|
||||
|
||||
assert move_to_trash(target) is None
|
||||
|
||||
assert not target.exists()
|
||||
assert (home_trash / "files" / "gone.txt").read_text() == "x"
|
||||
entry = (home_trash / "info" / "gone.txt.trashinfo").read_text()
|
||||
assert entry.startswith("[Trash Info]")
|
||||
assert "Path=" in entry
|
||||
assert "DeletionDate=" in entry
|
||||
|
||||
|
||||
@freedesktop_only
|
||||
def test_move_to_trash_moves_a_directory_in_one_piece(home_trash: Path, tmp_path: Path) -> None:
|
||||
target = tmp_path / "box"
|
||||
target.mkdir()
|
||||
_ = (target / "inner.txt").write_text("x")
|
||||
|
||||
assert move_to_trash(target) is None
|
||||
|
||||
assert not target.exists()
|
||||
assert (home_trash / "files" / "box" / "inner.txt").read_text() == "x"
|
||||
|
||||
|
||||
@freedesktop_only
|
||||
def test_trashing_twice_keeps_both_copies(home_trash: Path, tmp_path: Path) -> None:
|
||||
first = tmp_path / "dup.txt"
|
||||
second = tmp_path / "sub" / "dup.txt"
|
||||
second.parent.mkdir()
|
||||
_ = first.write_text("one")
|
||||
_ = second.write_text("two")
|
||||
|
||||
assert move_to_trash(first) is None
|
||||
assert move_to_trash(second) is None
|
||||
|
||||
names = sorted(path.name for path in (home_trash / "files").iterdir())
|
||||
assert names == ["dup 1.txt", "dup.txt"]
|
||||
|
||||
|
||||
def test_a_refused_volume_names_the_escape_hatch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
target = tmp_path / "gone.txt"
|
||||
_ = target.write_text("x")
|
||||
|
||||
def _refuse(_paths: object) -> None:
|
||||
raise send2trash.TrashPermissionError(str(target))
|
||||
|
||||
monkeypatch.setattr(send2trash, "send2trash", _refuse)
|
||||
|
||||
error = move_to_trash(target)
|
||||
|
||||
assert error is not None
|
||||
assert error.startswith("error: cannot move to trash:")
|
||||
assert "permanent=true" in error
|
||||
assert target.is_file()
|
||||
|
||||
|
||||
def test_an_oserror_is_reported(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
target = tmp_path / "gone.txt"
|
||||
_ = target.write_text("x")
|
||||
|
||||
def _fail(_paths: object) -> None:
|
||||
raise OSError("disk on fire")
|
||||
|
||||
monkeypatch.setattr(send2trash, "send2trash", _fail)
|
||||
|
||||
assert move_to_trash(target) == "error: cannot move to trash: disk on fire"
|
||||
assert target.is_file()
|
||||
|
||||
|
||||
def test_a_broken_install_leaves_the_file_alone(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
target = tmp_path / "gone.txt"
|
||||
_ = target.write_text("x")
|
||||
monkeypatch.setattr(trash, "is_available", lambda: False)
|
||||
|
||||
error = move_to_trash(target)
|
||||
|
||||
assert error is not None
|
||||
assert "send2trash is not installed" in error
|
||||
assert "permanent=true" in error
|
||||
assert target.is_file()
|
||||
|
||||
|
||||
def test_under_system_temp(tmp_path: Path) -> None:
|
||||
assert under_system_temp(tmp_path)
|
||||
assert under_system_temp(tmp_path / "nested" / "file.txt")
|
||||
assert not under_system_temp(Path("/plyngent-not-temp/deep/file.txt"))
|
||||
@@ -0,0 +1,68 @@
|
||||
"""``view_image``: a local picture reaches the model as a picture."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import ToolOutput
|
||||
from plyngent.tools.file.read import read_file
|
||||
from plyngent.tools.file.view_image import view_image
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
# A real 1x1 PNG: the format is sniffed from these bytes.
|
||||
PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
async def test_view_image_attaches_the_picture(workspace: Path) -> None:
|
||||
(workspace / "shot.png").write_bytes(PNG)
|
||||
|
||||
output = await view_image.handler("shot.png")
|
||||
|
||||
assert isinstance(output, ToolOutput)
|
||||
assert output.text == "image: shot.png (attached below)"
|
||||
url = output.images[0].image_url.url
|
||||
assert url.startswith("data:image/png;base64,")
|
||||
assert base64.b64decode(url.partition(",")[2]) == PNG
|
||||
|
||||
|
||||
async def test_the_detail_level_reaches_the_part(workspace: Path) -> None:
|
||||
(workspace / "shot.png").write_bytes(PNG)
|
||||
|
||||
output = await view_image.handler("shot.png", detail="low")
|
||||
|
||||
assert isinstance(output, ToolOutput)
|
||||
assert output.images[0].image_url.detail == "low"
|
||||
|
||||
|
||||
async def test_an_unknown_detail_level_is_an_error(workspace: Path) -> None:
|
||||
(workspace / "shot.png").write_bytes(PNG)
|
||||
|
||||
assert "detail must be one of" in await view_image.handler("shot.png", detail="huge")
|
||||
|
||||
|
||||
async def test_a_non_image_file_is_an_error(workspace: Path) -> None:
|
||||
(workspace / "notes.txt").write_text("hello")
|
||||
|
||||
result = await view_image.handler("notes.txt")
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "not a JPEG, PNG, GIF, or WebP image" in result
|
||||
|
||||
|
||||
async def test_a_missing_file_is_an_error(workspace: Path) -> None:
|
||||
del workspace
|
||||
|
||||
assert await view_image.handler("nope.png") == "error: file not found: nope.png"
|
||||
|
||||
|
||||
async def test_read_file_points_at_view_image_for_a_picture(workspace: Path) -> None:
|
||||
(workspace / "shot.png").write_bytes(PNG)
|
||||
|
||||
result = await read_file.handler("shot.png")
|
||||
|
||||
assert result == "error: shot.png is an image (image/png); use view_image to look at it"
|
||||
@@ -7,12 +7,14 @@ from plyngent.tools import (
|
||||
WorkspaceError,
|
||||
active_workspace_policy,
|
||||
check_command_allowed,
|
||||
get_trash_enabled,
|
||||
get_workspace_root,
|
||||
mode_covers,
|
||||
parse_access_mode,
|
||||
resolve_path,
|
||||
set_command_denylist,
|
||||
set_path_denylist,
|
||||
set_trash_enabled,
|
||||
)
|
||||
from plyngent.tools.context import SessionState, bind_session
|
||||
|
||||
@@ -55,6 +57,16 @@ def test_access_mode_parse_and_covers() -> None:
|
||||
assert not mode_covers(AccessMode.READ, AccessMode.WRITE)
|
||||
|
||||
|
||||
def test_trash_switch(workspace: object) -> None:
|
||||
"""Deletes go to the trash until a host clears the switch."""
|
||||
del workspace
|
||||
assert get_trash_enabled() is True
|
||||
set_trash_enabled(enabled=False)
|
||||
assert get_trash_enabled() is False
|
||||
set_trash_enabled(enabled=True)
|
||||
assert get_trash_enabled() is True
|
||||
|
||||
|
||||
def test_config_grant_modes(workspace: object, tmp_path_factory: pytest.TempPathFactory) -> None:
|
||||
del workspace
|
||||
outside = tmp_path_factory.mktemp("granted")
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Minimal stubs for the optional ``desktop-notifier`` extra.
|
||||
# Only the surface ``plyngent.cli.notify`` uses is declared; the real package is
|
||||
# installed with ``pip install plyngent[notify]`` and is absent by default.
|
||||
|
||||
from collections.abc import Sequence
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
class Capability(Enum):
|
||||
APP_NAME = ...
|
||||
TITLE = ...
|
||||
MESSAGE = ...
|
||||
URGENCY = ...
|
||||
ICON = ...
|
||||
BUTTONS = ...
|
||||
REPLY_FIELD = ...
|
||||
ATTACHMENT = ...
|
||||
ON_DISPATCHED = ...
|
||||
ON_CLICKED = ...
|
||||
ON_DISMISSED = ...
|
||||
SOUND = ...
|
||||
THREAD = ...
|
||||
TIMEOUT = ...
|
||||
|
||||
class Button:
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
on_pressed: Any = None,
|
||||
identifier: str | None = None,
|
||||
) -> None: ...
|
||||
|
||||
class ReplyField:
|
||||
def __init__(
|
||||
self,
|
||||
title: str = ...,
|
||||
button_title: str = ...,
|
||||
on_replied: Any = None,
|
||||
) -> None: ...
|
||||
|
||||
class DesktopNotifier:
|
||||
def __init__(
|
||||
self,
|
||||
app_name: str = ...,
|
||||
app_icon: Any = ...,
|
||||
notification_limit: int | None = None,
|
||||
) -> None: ...
|
||||
async def get_capabilities(self) -> frozenset[Capability]: ...
|
||||
async def send(
|
||||
self,
|
||||
title: str,
|
||||
message: str,
|
||||
urgency: Any = ...,
|
||||
icon: Any = ...,
|
||||
buttons: Sequence[Button] = ...,
|
||||
reply_field: ReplyField | None = None,
|
||||
on_dispatched: Any = None,
|
||||
on_clicked: Any = None,
|
||||
on_dismissed: Any = None,
|
||||
attachment: Any = ...,
|
||||
sound: Any = ...,
|
||||
thread: str | None = None,
|
||||
timeout: int = -1, # noqa: ASYNC109 - the library's own parameter name
|
||||
) -> str: ...
|
||||
async def clear(self, identifier: str) -> None: ...
|
||||
async def clear_all(self) -> None: ...
|
||||
@@ -0,0 +1,9 @@
|
||||
# Minimal stubs for ``send2trash`` (a required dependency).
|
||||
# Only the surface ``plyngent.tools.trash`` uses is declared; the real package is
|
||||
# pure Python over ctypes (freedesktop trash spec / Finder trash / Recycle Bin).
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
class TrashPermissionError(PermissionError): ...
|
||||
|
||||
def send2trash(paths: str | Iterable[str]) -> None: ...
|
||||
Reference in New Issue
Block a user