mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| caecf62f47 | |||
| 102c377adc | |||
| beaaaea2d9 | |||
| 475f7da645 | |||
| ce4fa80176 | |||
| 36e37e2a6f | |||
| 280daa651d | |||
| 6e69e5046c | |||
| 87f7166a77 | |||
| 1c319f13b6 | |||
| dbb68e7273 | |||
| a2d8159267 | |||
| 9c6222d742 | |||
| 8cede4cbb3 | |||
| 4845455d4b | |||
| bf3a01854c | |||
| abd8c4d805 | |||
| 3631af2d60 | |||
| 3a54745d01 | |||
| b7044bbba7 | |||
| d4485aff7a | |||
| 0244a48998 | |||
| c1b40a7bf4 | |||
| 12236cffb5 | |||
| 3cb4d490ed | |||
| e1c857fd05 | |||
| 8c289db545 | |||
| 0cb740393e | |||
| d5c3f712ff | |||
| 16c5e01e44 | |||
| 80ad34f8fe | |||
| b2d6acae26 | |||
| 8926980a02 | |||
| 37e9395ac5 | |||
| da6f4b7dd7 |
@@ -83,7 +83,7 @@ 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.
|
- **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.
|
- **`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`).
|
- **`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.
|
- **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, and every image-carrying path checks it — an `@path` mention, `view_image`, `fetch_local`'s image body, and (as a safety net in the registry) an MCP server's or plugin's `ToolOutput`.
|
||||||
- **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.
|
- **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. 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.
|
- **`@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`.
|
- **`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`.
|
||||||
@@ -100,10 +100,10 @@ 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'`).
|
- **`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.
|
- **`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; 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=...`).
|
- **`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).
|
- **`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` (key markers) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
|
||||||
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`\xHH`, `ctrl+x`, `key=esc`); secrets via `ask_into_pty` (not `write_pty` data); session limit/idle TTL/output budget; close terminate→kill.
|
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`<c-c>`, `<esc>`, `<c-up>`, `<c-s-f5>`, `<up*3>`, `<m-x>`, `\xHH` — xterm modifier sequences where they exist; an unknown `<…>` is an error, never typed); 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.
|
- 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. 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.
|
- 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` — every wrapper's options are spelled as a `getopt` spec (`command_scan._WRAPPERS`: flags as well as value options, so no option swallows the program), and an option the spec does not know stops the unwrap and is reviewed (`options not understood … the wrapped program is a guess`) while the wrapper itself stays the command the denylist judges; deny may include a user comment for the model. `ssh` / `mosh` are unwrapped like a wrapper (`command_scan`): the program reviewed is the one the client runs on the far end (`ssh prod rm -rf /` is refused as `rm`, `ssh prod bash -c …` prints the remote code, an escalation behind the hop keeps `yolo_skip=False`), and `-o BatchMode=yes` always asks because nothing on either end can prompt. A client with **no** command only opens a remote shell: nothing typed or piped into it (`write_pty`, `run_argv(stdin=…)`) is checked, so opening one is announced and confirmed instead — `remote shell on 'prod' (ssh with no command; its input is not checked)` — while the client itself stays outside the denylist (there is no program to name). 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.
|
- **`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.
|
- **`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`).
|
- **`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`).
|
||||||
@@ -117,7 +117,7 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
|||||||
|
|
||||||
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`).
|
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.
|
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, choices)`, which yields the `PromptAnswerSource` the read may consult (`take()` returns `allow` / `deny` / reply text, or any key the prompt named in `choices`, without blocking) or `None` for notify-only. Only the outermost prompt of a nested read is announced; with no hook installed nothing changes. A source that *will* offer an answer says so through `supports_actions` before it offers it, so the read polls from its first moment instead of deciding once. `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` — except one that follows an answer given on the notification, which goes out at once and past the burst throttle — `choices` become the notification's buttons (plus "Go to terminal"), the answer is treated like a typed one (never a session grant), the notification is sent without expiry (`timeout=0`, urgency at the library default) so it stays on screen until it is dismissed, and every failure falls through to the next channel instead of raising.
|
||||||
|
|
||||||
### CLI (`cli/`)
|
### CLI (`cli/`)
|
||||||
|
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ 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).
|
**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).
|
**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 the answers that prompt accepts as buttons where the platform renders them (a confirmation: **Allow once / Deny / Go to terminal**; a limit prompt: **Continue / Continue, stop asking / Stop here**; a skill write: **Allow once / This chat / Deny**) and a reply field for a question — 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, and a prompt that follows an answer given on the notification is announced at once; `[notify].include_reason` keeps the reason line (a command, argv or URL) out by default; the notification is kept on screen (no expiry) until the prompt is answered instead of collapsing into the tray; 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).
|
**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).
|
||||||
|
|
||||||
@@ -357,7 +357,7 @@ Safety defaults:
|
|||||||
- PTY sessions: caps, idle TTL, output budget; master FD is non-inheritable; sessions closed on chat exit.
|
- PTY sessions: caps, idle TTL, output budget; master FD is non-inheritable; sessions closed on chat exit.
|
||||||
Prefer file tools over full-screen editors (`vim`/`nano`) for edits. `read_pty` sanitizes CSI/controls
|
Prefer file tools over full-screen editors (`vim`/`nano`) for edits. `read_pty` sanitizes CSI/controls
|
||||||
so tool results cannot reprogram the host TTY (no host terminal reset on exit).
|
so tool results cannot reprogram the host TTY (no host terminal reset on exit).
|
||||||
`write_pty` is literal text only; use `write_pty_keys` for `\xHH`, `ctrl+x`, `key=esc|enter|…`.
|
`write_pty` is literal text only; use `write_pty_keys` for keys and bytes: `<c-c>` `<esc>` `<enter>` `<s-tab>` `<up>` … `<f12>`, `<c-up>` `<s-home>` `<c-s-f5>` for modifier combinations, `<up*3>` to repeat, `<m-x>` for Alt, and `\xHH` / `\uHHHH` for raw bytes. An unknown `<…>` is an error, never text typed into the terminal.
|
||||||
For passwords/sudo/ssh prompts use `ask_into_pty` (human types locally; answer never returns to the model).
|
For passwords/sudo/ssh prompts use `ask_into_pty` (human types locally; answer never returns to the model).
|
||||||
|
|
||||||
- Mis-typed tool arguments are answered, never guessed: a string where an array is expected (`run_argv` `argv`, `ask_user_choice` `options`, `ask_user_form` `fields`), a call that omits a required argument, or a non-numeric `wait` duration (numeric strings are accepted) returns an error showing the shape/names to send — the human never sees a garbage prompt, and no confirm prompt fires for a call that cannot run.
|
- Mis-typed tool arguments are answered, never guessed: a string where an array is expected (`run_argv` `argv`, `ask_user_choice` `options`, `ask_user_form` `fields`), a call that omits a required argument, or a non-numeric `wait` duration (numeric strings are accepted) returns an error showing the shape/names to send — the human never sees a garbage prompt, and no confirm prompt fires for a call that cannot run.
|
||||||
|
|||||||
+57
-7
@@ -53,6 +53,40 @@ 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
|
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.
|
lives inside a user turn, which is why it can carry one.
|
||||||
|
|
||||||
|
## Files API (upload once, reference by id)
|
||||||
|
|
||||||
|
Every surface we speak to can take a picture as an uploaded file instead of
|
||||||
|
inline bytes. The upload half of that is de-facto one shape; the object it
|
||||||
|
returns and the way a message refers to it are not standardized at all. Field
|
||||||
|
names below come from each vendor's own reference (DeepSeek `guides/files_api`,
|
||||||
|
OpenAI `resources/files` + `guides/file-inputs`, Anthropic `api/beta/files`).
|
||||||
|
|
||||||
|
| | OpenAI | Anthropic | DeepSeek |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Upload | `POST /v1/files`, multipart: `file` + `purpose` (`user_data`, `vision`, …), optional `expires_after[anchor]` / `[seconds]` | `POST /v1/files`, multipart: `file` only; needs `anthropic-version` and `anthropic-beta: files-api-2025-04-14` | `POST /files`, OpenAI shape (`file` + `purpose="user_data"`, optional `expires_after[…]`); the same operations are also served under `/anthropic/v1/files` in the Anthropic shape |
|
||||||
|
| Id | `file-abc123` | `file_01HX…` | `file-api-…` |
|
||||||
|
| File object | `object:"file"`, `bytes`, `created_at` (unix seconds), `filename`, `purpose`, `expires_at` when it was asked for | `type:"file"`, `size_bytes`, `created_at` (RFC 3339), `filename`, `mime_type`, `downloadable` | both dialects: OpenAI shape on `/files`, Anthropic shape on `/anthropic/v1/files` |
|
||||||
|
| Reference in a message | Responses `{"type":"input_image","file_id":…}` (mutually exclusive with `image_url`); chat completions cannot reference an image by id at all — its `image_url` part has no `file_id`, and its `file` part is documents only | `{"type":"image","source":{"type":"file","file_id":…}}`, plus the beta header | chat: `{"type":"file","file_id":…}`, or inline `file_data` + `filename`; Anthropic: `source.type="file"` plus beta; Responses: `input_image.file_id` |
|
||||||
|
| Limits | 512 MB per file (what `purpose` allows) | not verified here (the API reference is unreachable from this host's region) | 64 MiB per file, 25 GiB and 10 000 files per account, expiry 1 h – 30 d or permanent |
|
||||||
|
|
||||||
|
What is shared: `POST …/files` as `multipart/form-data` with a `file` part,
|
||||||
|
and a string id back. What is not: the field names (`bytes` vs `size_bytes`),
|
||||||
|
the type tag (`object` vs `type`), the timestamp format, the cursor and paging
|
||||||
|
fields, the beta header, and every reference block above. A client therefore
|
||||||
|
needs one uploader and three reference shapes — the split this module already
|
||||||
|
has.
|
||||||
|
|
||||||
|
Three rules if this is ever wired in:
|
||||||
|
|
||||||
|
- a `file_id` is scoped to the provider (and account) that minted it, so a
|
||||||
|
stored id belongs to the surface that produced it, exactly like
|
||||||
|
`AssistantChatMessage.provider_blocks`; `/provider` cannot carry it across;
|
||||||
|
- OpenAI's chat-completions surface cannot reference an image id, so on that
|
||||||
|
provider the feature implies the Responses convention;
|
||||||
|
- the token bill does not change: the model still processes the picture in
|
||||||
|
every request that carries it. Uploading buys the 32 MiB inline cap, a
|
||||||
|
smaller request body, and not re-sending the same screenshot each round.
|
||||||
|
|
||||||
## Verified against the DeepSeek API
|
## Verified against the DeepSeek API
|
||||||
|
|
||||||
The 2026-10-17 docs plus a live probe (`deepseek-flash`):
|
The 2026-10-17 docs plus a live probe (`deepseek-flash`):
|
||||||
@@ -93,16 +127,32 @@ completed in its quoted form).
|
|||||||
|
|
||||||
## Opting in
|
## Opting in
|
||||||
|
|
||||||
`[providers.<name>.models."<model>"].image_in` is the gate — an `@image` mention
|
`[providers.<name>.models."<model>"].image_in` is the gate, and it covers every
|
||||||
is skipped, with a note naming the key, on a model that does not set it, and the
|
way a picture can enter a turn — not only an `@image` mention:
|
||||||
default catalogs mark what takes pictures (`deepseek-flash` yes,
|
|
||||||
|
| Path | Where it is gated |
|
||||||
|
|---|---|
|
||||||
|
| an `@image` mention (`cli/mentions.py`) | skipped, with a note naming the key |
|
||||||
|
| the `view_image` tool | `error: model '…' does not accept image input (set image_in = true under [providers.x.models."y"])` |
|
||||||
|
| an image body from `fetch_local` | the metadata stays, the picture is left behind with the same reason |
|
||||||
|
| an MCP server's image content, a plugin's `ToolOutput` | dropped by the registry's safety net (`agent/tools.py`), with the reason appended to the text |
|
||||||
|
|
||||||
|
`ReplState.sync_image_capability` publishes the selected model's verdict on
|
||||||
|
`InstanceState.workspace` and runs on every provider/model/client rebuild, so a
|
||||||
|
`/model` switch flips the gate at once. A host that binds no instance declares
|
||||||
|
nothing, and the picture is sent as usual.
|
||||||
|
|
||||||
|
The point is to fail loudly: without the gate a text-only model answers
|
||||||
|
`[Unsupported Image]` one round later, and a gateway that cannot read image
|
||||||
|
blocks hands the model raw base64 — which reads like the picture arrived when it
|
||||||
|
did not.
|
||||||
|
|
||||||
|
The default catalogs mark what takes pictures (`deepseek-flash` yes,
|
||||||
`deepseek-v4-pro` no, OpenAI's defaults yes). A model missing from the catalog
|
`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.
|
has to be declared: the flag is an explicit opt-in.
|
||||||
|
|
||||||
## Not implemented
|
## Not implemented
|
||||||
|
|
||||||
- **Files API** (`POST /files` → `file_id`): it lifts the 32 MiB per-image cap
|
- **Files API**: see above — no image path uses it today; a local 8 MiB cap plus
|
||||||
and avoids re-uploading the same bytes, but it does not change the token bill
|
resizing covers the common case, and the upload changes no token cost.
|
||||||
(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.
|
- **Image output**: the API documents none.
|
||||||
|
|||||||
+38
-8
@@ -8,7 +8,9 @@ answering at the terminal stays silent. Bursts are throttled by
|
|||||||
`min_interval_seconds`, and the announcement says what is being asked, not what
|
`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
|
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
|
notification outlives the prompt (and may be mirrored to a lock screen), so it
|
||||||
travels only with `include_reason = true`.
|
travels only with `include_reason = true`. The title is the prompt line and the
|
||||||
|
body is nothing but that reason — a channel gives the title a field of its own,
|
||||||
|
so repeating it in the body would only say the same thing twice.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -32,18 +34,36 @@ a prompt answered inside the grace period is never announced at all.
|
|||||||
|
|
||||||
| Prompt | Notification offers |
|
| Prompt | Notification offers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| tool confirm (single / a round's batch) | Allow once / Deny / Go to terminal |
|
| tool confirm (single) | Allow once / Deny / Go to terminal |
|
||||||
| command denylist, `fetch_local` private host | same three |
|
| a round's batch of confirms | Allow all / Review one by one / Deny / Go to terminal |
|
||||||
| directory-access request, skill write | same three |
|
| command denylist, `fetch_local` private host | Allow once / Deny / Go to terminal |
|
||||||
|
| directory-access request | same three (the level asked for, not a lower one) |
|
||||||
|
| skill write | Allow once / This chat / Deny / Go to terminal |
|
||||||
|
| limit-continue | Continue / Continue, stop asking / Stop here / Go to terminal |
|
||||||
| `ask_user_line` | a reply field — the answer *is* the text you type |
|
| `ask_user_line` | a reply field — the answer *is* the text you type |
|
||||||
| `ask_user_choice`, `ask_user_form`, PTY secrets | nothing (terminal only) |
|
| `ask_user_choice`, `ask_user_form`, PTY secrets | nothing (terminal only) |
|
||||||
| limit-continue | nothing (terminal only) |
|
|
||||||
|
Each prompt says which answers it accepts (`prompt_wait(..., choices=…)`), and
|
||||||
|
those are the buttons; one that names none gets Allow once / Deny / Go to
|
||||||
|
terminal. "Go to terminal" is always the last button: it withdraws the
|
||||||
|
notification and leaves the prompt waiting.
|
||||||
|
|
||||||
An answer read from the notification is treated exactly like a typed one —
|
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
|
`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
|
session grant: the button means "this once", which is why a prompt with an
|
||||||
notification and leaves the prompt waiting. A secret is never asked for in a
|
extra answer (this chat, stop asking) offers it as a button of its own. A secret
|
||||||
notification, and never echoed into one.
|
is never asked for in a notification, and never echoed into one.
|
||||||
|
|
||||||
|
A prompt that can be answered from the notification reads both surfaces while it
|
||||||
|
blocks, from the first moment — the notification itself only shows up after
|
||||||
|
`grace_seconds`, so waiting for it to appear before listening would block on the
|
||||||
|
terminal alone. Typing at the terminal always wins; a readline-only convenience
|
||||||
|
(Tab completion on `ask_user_line`) is what a question gives up in exchange.
|
||||||
|
|
||||||
|
A prompt that *follows* an answer given on the notification — the "why denied"
|
||||||
|
comment, the per-call prompts after "Review one by one" — is announced at once,
|
||||||
|
with no grace period and past the burst throttle: the human is looking at the
|
||||||
|
notification, and the follow-up is the next thing they must answer.
|
||||||
|
|
||||||
## Desktop channel
|
## Desktop channel
|
||||||
|
|
||||||
@@ -64,6 +84,16 @@ 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
|
daemon thread, because callbacks only fire while the loop that owns the
|
||||||
notification is running.
|
notification is running.
|
||||||
|
|
||||||
|
The notification stays on screen: it is sent with no expiry (`timeout = 0`, the
|
||||||
|
freedesktop value for "until dismissed"), so the server does not slide it into
|
||||||
|
the tray a few seconds later. That is the point of it — the turn is blocked
|
||||||
|
until the prompt is answered, and a collapsed notification is one the human
|
||||||
|
would find only on coming back anyway. Urgency stays at the library default: on
|
||||||
|
KDE Plasma, `critical` keeps the notification up just the same but paints the
|
||||||
|
popup as urgent, and on every platform it asks the Focus mode to let it through
|
||||||
|
— which a prompt has no business doing. Once the prompt is answered, the
|
||||||
|
notification is dismissed.
|
||||||
|
|
||||||
## Terminal channel
|
## Terminal channel
|
||||||
|
|
||||||
No dependency and no extra: the CLI writes an escape sequence to `/dev/tty`
|
No dependency and no extra: the CLI writes an escape sequence to `/dev/tty`
|
||||||
|
|||||||
@@ -45,13 +45,15 @@
|
|||||||
# Notifications while a prompt waits on the human: a tool confirmation, a
|
# Notifications while a prompt waits on the human: a tool confirmation, a
|
||||||
# directory-access grant, a skill write, an ``ask_user_*`` question, or the
|
# directory-access grant, a skill write, an ``ask_user_*`` question, or the
|
||||||
# limit-continue prompt. A prompt is announced only after `grace_seconds` of
|
# 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`
|
# waiting, so one you answer at the terminal stays silent — except a prompt that
|
||||||
# throttles bursts.
|
# follows an answer given on the notification, which is announced at once;
|
||||||
|
# `min_interval_seconds` throttles bursts.
|
||||||
# channels: "auto" tries the desktop first (needs the `notify` extra:
|
# channels: "auto" tries the desktop first (needs the `notify` extra:
|
||||||
# `pip install plyngent[notify]`) and falls back to a terminal escape sequence;
|
# `pip install plyngent[notify]`) and falls back to a terminal escape sequence;
|
||||||
# "desktop" / "terminal" pin one of them.
|
# "desktop" / "terminal" pin one of them.
|
||||||
# `actions` offers allow / deny / review as notification buttons; where the
|
# `actions` offers the answers the prompt accepts as notification buttons (allow /
|
||||||
# platform has none the notification stays text-only.
|
# deny / review when it names none); 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
|
# `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).
|
# it stays off by default (a notification may be mirrored to a lock screen).
|
||||||
# [notify]
|
# [notify]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
groups = ["default", "dev", "notify"]
|
groups = ["default", "dev", "notify"]
|
||||||
strategy = ["inherit_metadata"]
|
strategy = ["inherit_metadata"]
|
||||||
lock_version = "4.5.1"
|
lock_version = "4.5.1"
|
||||||
content_hash = "sha256:6b62075d8b95d83a7d5d6c99c073db457750af4c8a4e988adbef8799f6f4167e"
|
content_hash = "sha256:baade01b6537caca957e9fe9ee94e82f998e39255b52ed94db94b501e620398b"
|
||||||
|
|
||||||
[[metadata.targets]]
|
[[metadata.targets]]
|
||||||
requires_python = "~=3.14"
|
requires_python = "~=3.14"
|
||||||
@@ -659,29 +659,29 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.16.10"
|
version = "0.17.2"
|
||||||
requires_python = ">=3.7"
|
requires_python = ">=3.7"
|
||||||
summary = "An extremely fast Python linter and code formatter, written in Rust."
|
summary = "An extremely fast Python linter and code formatter, written in Rust."
|
||||||
groups = ["dev"]
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511"},
|
{file = "ruff-0.17.2-py3-none-linux_armv6l.whl", hash = "sha256:96a65e755d8dba094707fb23ab602111d9a31ec2835b3a6992a1914a8fcb211e"},
|
||||||
{file = "ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d"},
|
{file = "ruff-0.17.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:39c5b55c960515cdb32be16e69efb5734c589c06a0eb38a22694524a336b889d"},
|
||||||
{file = "ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550"},
|
{file = "ruff-0.17.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:92a80d2f44584dabb0189b3a6c393e37e525e04942838482010575cb81945be7"},
|
||||||
{file = "ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d"},
|
{file = "ruff-0.17.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7d0906677d89bbf3cf73cde64b5bbe2ab35df3ae43e49ee2627301e91265bac"},
|
||||||
{file = "ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221"},
|
{file = "ruff-0.17.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cfd783590edf2b77317882631900f6a13ea2e8eacefe6ebf2370d2715299160"},
|
||||||
{file = "ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a"},
|
{file = "ruff-0.17.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122258f6ecde0e0dd1bb60b24263c3edde4338caf723fca711cc729f9bfd414a"},
|
||||||
{file = "ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8"},
|
{file = "ruff-0.17.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f8fba429d6ac4128ab29cfb8fa6240d2fa671d04adf7f84d3b22fc7b6b6bea4"},
|
||||||
{file = "ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e"},
|
{file = "ruff-0.17.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:583bd67bb9e7061dd09bc46a0a884bae2bbd214dbcd48e509813612619683869"},
|
||||||
{file = "ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a"},
|
{file = "ruff-0.17.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c60f93fec7142dba5b8f487f94bf2f6231dae24dc11e4c48648347c8b0720004"},
|
||||||
{file = "ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3"},
|
{file = "ruff-0.17.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c555a877f8f41447e318101cfefea9bf12dfa9f82eedf90c376e57e069f917d0"},
|
||||||
{file = "ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3"},
|
{file = "ruff-0.17.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ba405cbd5ec7bda1f555d5e52be1fd68d02608d4d95d24591bdac92cfacb579f"},
|
||||||
{file = "ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03"},
|
{file = "ruff-0.17.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8c21b4ab24485358f17291a4f8dc5399b834e963b8efedd920fe4fdb02081bb4"},
|
||||||
{file = "ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be"},
|
{file = "ruff-0.17.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5875a16b0de70b43cb81eabea5ae5e8f5d1b2fd1b5e6ed07f80cfe5019d0bd5a"},
|
||||||
{file = "ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d"},
|
{file = "ruff-0.17.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d086be452eed686aa51271ecf2cbe5037956517cc6ee39a17b4489ac12f8bbe"},
|
||||||
{file = "ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a"},
|
{file = "ruff-0.17.2-py3-none-win32.whl", hash = "sha256:6bce7d1f5165701ae18f5df85c65afe78cddfa6c5fb4b9bdb0014d60388f3edd"},
|
||||||
{file = "ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca"},
|
{file = "ruff-0.17.2-py3-none-win_amd64.whl", hash = "sha256:1b59d447f197bef9ef48a22ba1a6016137753c6099e9bac9c36eb566e422424c"},
|
||||||
{file = "ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b"},
|
{file = "ruff-0.17.2-py3-none-win_arm64.whl", hash = "sha256:195ad918027f817c119310ca89a42d347887dfc28934aaa869325e9c5dc9d962"},
|
||||||
{file = "ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017"},
|
{file = "ruff-0.17.2.tar.gz", hash = "sha256:84d632402d14c23b488f941db23712d6736a013225162062d34558c3f3c780eb"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -697,7 +697,7 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "2.1.5"
|
version = "2.1.6"
|
||||||
requires_python = ">=3.11"
|
requires_python = ">=3.11"
|
||||||
summary = "Database Abstraction Library"
|
summary = "Database Abstraction Library"
|
||||||
groups = ["default"]
|
groups = ["default"]
|
||||||
@@ -705,48 +705,48 @@ dependencies = [
|
|||||||
"typing-extensions>=4.6.0",
|
"typing-extensions>=4.6.0",
|
||||||
]
|
]
|
||||||
files = [
|
files = [
|
||||||
{file = "sqlalchemy-2.1.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5541a110ac4bb6377c785aea1b8f0b4d6658ed0ca5061ed1115d0725062b026b"},
|
{file = "sqlalchemy-2.1.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5f9400b1234555b9ae78450123dec7aaf045d8d978a1e94967437ebe561ae120"},
|
||||||
{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.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d106c2191a3a0dea6873c96ab909f31c13978913714a25023f492e9ccc8ffcc7"},
|
||||||
{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.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39ca3a128260fb3aa1c33554f1993bc5ddf3f82375697028c49e7b411ab80b84"},
|
||||||
{file = "sqlalchemy-2.1.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:72ddd7a8ec187d02de0ef316a15aaf381ddcc594958247b02a3dcdb4c42f0b02"},
|
{file = "sqlalchemy-2.1.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8c34c200a124a1a5769671613b3fc05a21d0a50f7ad942cf0ee12d4390a112c"},
|
||||||
{file = "sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f5173a6e60a15090f9e2348a8ca94cc13de72796c16733f3b62fbb22acba07b8"},
|
{file = "sqlalchemy-2.1.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:17bc5f508f5e5cee2722a5a14af0a48768b40e1fc5d1bfc92abf6ac587636bd7"},
|
||||||
{file = "sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:09b10bd68223d57cce39c08947efae3f91ac40b552e07c7c869c3350a0fc4c23"},
|
{file = "sqlalchemy-2.1.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c757e13b63a25d59a2566c9b2e77adf14d969207a375ca82a9b4fa1a04b37845"},
|
||||||
{file = "sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ea4cb5022462d9f15e7b72a52c267385aa97ac224c8503ac63ba90c0067ad0d9"},
|
{file = "sqlalchemy-2.1.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:73aa8d2f911a54d1310e8258a752207e2d7624f4db46e2320d734fe7218320a1"},
|
||||||
{file = "sqlalchemy-2.1.5-cp314-cp314-win32.whl", hash = "sha256:c3ffbe6c680c626403b7e9697d92465501078731b9e8863b1410826e66b950e1"},
|
{file = "sqlalchemy-2.1.6-cp314-cp314-win32.whl", hash = "sha256:1927eabf26265ed962627e7a64626d9058da29cc0faea341091302ec108ce305"},
|
||||||
{file = "sqlalchemy-2.1.5-cp314-cp314-win_amd64.whl", hash = "sha256:061d089901319463a1160e4596191e355d2a9b4cce4852dac5adc301b085ac2f"},
|
{file = "sqlalchemy-2.1.6-cp314-cp314-win_amd64.whl", hash = "sha256:fd1154c6394892e727ff234273f5eb2b4319e5714917b016602435d77add8dcb"},
|
||||||
{file = "sqlalchemy-2.1.5-cp314-cp314-win_arm64.whl", hash = "sha256:f5bc7fb93f1c17efefeb062bc4d2742ad1b71d56c36e6e2898a371ac6a56839a"},
|
{file = "sqlalchemy-2.1.6-cp314-cp314-win_arm64.whl", hash = "sha256:10a4fe106adbb46c9452f750d8a5d4013d9a7e9752f63631c3b255c062b748d0"},
|
||||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9bb993b1d0890a2da90589684c3d09de75df82c06aa8f7eda246c7cd0ea3e820"},
|
{file = "sqlalchemy-2.1.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea5f3dd7450669acbfee7813d3a2a13876806b1d9e4c1626a7bf587c33c68472"},
|
||||||
{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.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4e4782514c16753f8bce3dd925cd37d26c847bead06e179af6fa4993ce83e88"},
|
||||||
{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.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da21b36d60f47dd04042e9372ffb3d1ededf5c60a76d43f8c3592221fb2d41cd"},
|
||||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59a56be2e4e972061f9d5c1d5b01763b8b2207b48b956fbb6879c7443f5817f7"},
|
{file = "sqlalchemy-2.1.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:048d1beb9bfd7ca1afbcfdb8da9519712827df6cccc80a225715ce387d37bb3b"},
|
||||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc1a61d7f42e71746b5a2e10e90a6b174b4add441b7b6cf14278f1e029b2b800"},
|
{file = "sqlalchemy-2.1.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8de444335e3723959913f6635d579276a3517b8c19cbdfaefb04e73b5480e88b"},
|
||||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:740e96dfb99a615c8056070d3747efe8bce4c145e5953ccc9e4b004c139de0d2"},
|
{file = "sqlalchemy-2.1.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:231d0fd85866f847c34c915116c386c83e677ab6f79a94c835b7ae7313b86095"},
|
||||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:92a0bb0595331c8e40bc615a67cc7bca9b761487409d1bf46c598b6ba5c0754d"},
|
{file = "sqlalchemy-2.1.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bf937348d4052eac6cfa93c809ad214be75b1f057e3fc0dd2e374a814a23a16b"},
|
||||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-win32.whl", hash = "sha256:11185c72b5372369166cb721537943f8e1e6e42a399e9bac19e02f3ee20592a4"},
|
{file = "sqlalchemy-2.1.6-cp314-cp314t-win32.whl", hash = "sha256:bc8b805fb04472e0f9cb3bc6aad90f24b202afc274b7b8bbd48042130e82997a"},
|
||||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5173207b8fe1ddfc1d451c420a2a9cbbd0734b4a19896b8080e3744497e1c20c"},
|
{file = "sqlalchemy-2.1.6-cp314-cp314t-win_amd64.whl", hash = "sha256:84033cab9fec2a515a62d76a7b4acafa83339917db9ea911b65b017928ae7dd8"},
|
||||||
{file = "sqlalchemy-2.1.5-cp314-cp314t-win_arm64.whl", hash = "sha256:def2f117a6ec0a25202ee36a148bbcfad3d43e2f2bb2e698e6d4448c6db5cb19"},
|
{file = "sqlalchemy-2.1.6-cp314-cp314t-win_arm64.whl", hash = "sha256:96ace18758136905f8d5588ec35d6ae4fdc29ce4e4902518263ca33abf10de04"},
|
||||||
{file = "sqlalchemy-2.1.5-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:a0b80f91d9ed92be7a134278ea2e83a693c07d9986824ca51d859af9e57e18f5"},
|
{file = "sqlalchemy-2.1.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:89d05a1ef531a1fee86d68e731c428b773bd37aabf743bf63dc3709c86f7254d"},
|
||||||
{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.6-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e12763cb2a400879c6dfe34995a032f707de635c8cad14d6ba777a1250be90b"},
|
||||||
{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.6-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6e1a9ae71366164800942c477e663b31f3accef55d2e039cb7919fdb54b0086"},
|
||||||
{file = "sqlalchemy-2.1.5-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cefa5badc13df1c9082bacdfa435c8eb6dcb9b32717f51d09d1e160a599646f"},
|
{file = "sqlalchemy-2.1.6-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9bd573f8da863781ace89950d57a959397d5048b1cf69931c29b2b4ce1405bd7"},
|
||||||
{file = "sqlalchemy-2.1.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2549e503185897de1fc5ca7730d5daed0d27d0b26df4a059dcdd33ebe81b7b4b"},
|
{file = "sqlalchemy-2.1.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0d0424a4189f95886c78b48bb500a1dd7ae6dbae76deb552b15813dc298d9c8f"},
|
||||||
{file = "sqlalchemy-2.1.5-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:5a6871ee159a943d874012de835d56d0544ee871a99dbae05bc053da315b2c23"},
|
{file = "sqlalchemy-2.1.6-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:cc60fffea328f38187a4fce43f99c6bbf930de2b87b78251f43f2ba64a1af622"},
|
||||||
{file = "sqlalchemy-2.1.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:51e290080415325b66a94d20f6360e310606acb832f2bd677d4fcb95728e7ce1"},
|
{file = "sqlalchemy-2.1.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c390f76f2fe408aa1a3121f3ca3d5c15698e14d913197417355fe3dc016f787d"},
|
||||||
{file = "sqlalchemy-2.1.5-cp315-cp315-win32.whl", hash = "sha256:c228084025a28aa038aef9b821d842508650fe12fe51f06e9cdaa4393be7812c"},
|
{file = "sqlalchemy-2.1.6-cp315-cp315-win32.whl", hash = "sha256:79c1c20dffd4d8d7e669f096c88f261da8ba3646939cd3eb6faeccb92bae99c3"},
|
||||||
{file = "sqlalchemy-2.1.5-cp315-cp315-win_amd64.whl", hash = "sha256:bc09dd22c3fc491a2df7d16ca4167de35573f40c2e82dd4d6ddae589a73e7032"},
|
{file = "sqlalchemy-2.1.6-cp315-cp315-win_amd64.whl", hash = "sha256:f1e78019147ff7e08afaffb88fc3a0aa795171b147eacfc7ff873ce7a0d37dff"},
|
||||||
{file = "sqlalchemy-2.1.5-cp315-cp315-win_arm64.whl", hash = "sha256:375ef1bf78bc269a220963163d2580d5377898ed23a00bdd98a02d755421573d"},
|
{file = "sqlalchemy-2.1.6-cp315-cp315-win_arm64.whl", hash = "sha256:3974e33fc322b645eedeac8a3dea93022466fee41a6385c744a6656aba293cd4"},
|
||||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:7af1c92255cf8d50e87a808ce41096907332acdd09f285426a52f7c300e152db"},
|
{file = "sqlalchemy-2.1.6-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e269c68ec200538ae3fa7f9e8d8ed2b4b34d974c25945286ccc2a488438acf32"},
|
||||||
{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.6-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b25199a1b499da643de52ab73e972eb07e55e2a95bb99eaa491846098fa2214c"},
|
||||||
{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.6-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:641a3742dac4b8f325667b585097de0f1d20eb3498506c92d5f04c20b3a6c823"},
|
||||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b79ee943e2e12246f873345762b83dbadd1fcfc3e832eab83272d672704dbdea"},
|
{file = "sqlalchemy-2.1.6-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d96186af967d7ae3f76d1e9a8fb70b5beb0f0dbdacfd3b418a946fffbb8781ff"},
|
||||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:e9418d87b55c469c2f1328246d4533238cdc43134c695d6b9a6b7a87da49d463"},
|
{file = "sqlalchemy-2.1.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:5a9b4cbc5f5cda3c9429e1ac503a57f3b95c59886f26af6ffb44e2e7628b7df8"},
|
||||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:898d86c22640fe52ffe02b4abc043a3c6754b4875837599e739be440966dffa1"},
|
{file = "sqlalchemy-2.1.6-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b7ddb305126d34fda64c3fe4632a43fb9b724da6cc7d1dedd8434e6c783b285b"},
|
||||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b3ad20cd035392f2b91a6466e24643990c1378beda80c9bda87a3a41e841e838"},
|
{file = "sqlalchemy-2.1.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:70b435f719d7156623f49ac6539f30dd8471500b5acf399f3b318c1c17f6fa7f"},
|
||||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-win32.whl", hash = "sha256:db34eefaf992c84460ab26ecf2fc2bdcc68965d6becfae8182cae0efeaa3e8f8"},
|
{file = "sqlalchemy-2.1.6-cp315-cp315t-win32.whl", hash = "sha256:654192c9f192459ee5f63e6606cc6dd8c50681c28e0689d2a494fc9522b357c1"},
|
||||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-win_amd64.whl", hash = "sha256:173054351d863ddada188284e309e87a9f030132c8d534e05458971ff061aa1e"},
|
{file = "sqlalchemy-2.1.6-cp315-cp315t-win_amd64.whl", hash = "sha256:a053a9d9c9ccc9b72d90ecdcbabcbe57446defc7e8c52fc9978188f78742ac19"},
|
||||||
{file = "sqlalchemy-2.1.5-cp315-cp315t-win_arm64.whl", hash = "sha256:22c8d0873168a54f614e381559ea83d547b2cc5488b95b049bdc92667586bd5f"},
|
{file = "sqlalchemy-2.1.6-cp315-cp315t-win_arm64.whl", hash = "sha256:61dc3fa488208a2f03e0e25ad32950dc024545291acc7348f2e550ea1bc5abb4"},
|
||||||
{file = "sqlalchemy-2.1.5-py3-none-any.whl", hash = "sha256:2f77b77b716f005b2dc3cc059d56df6a919bfb5b439e4149890a9df6c36be9e3"},
|
{file = "sqlalchemy-2.1.6-py3-none-any.whl", hash = "sha256:106878cf3795cd3d357e2e858609f9e44f20f19b2d8e48a3532bef3f90b03fbe"},
|
||||||
{file = "sqlalchemy-2.1.5.tar.gz", hash = "sha256:0df76cf1ca680ad745bd378a6299a058611a8a784eeaeb4ed78ad4a0b7ce2003"},
|
{file = "sqlalchemy-2.1.6.tar.gz", hash = "sha256:499eae5c9051efe96cb9a73ec2fbde8ffa4186f102b9094ebab8fcc9d3b99f52"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -789,14 +789,14 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wassima"
|
name = "wassima"
|
||||||
version = "2.1.4"
|
version = "2.1.5"
|
||||||
requires_python = ">=3.7"
|
requires_python = ">=3.7"
|
||||||
summary = "Access your OS root certificates with utmost ease"
|
summary = "Access your OS root certificates with utmost ease"
|
||||||
groups = ["default"]
|
groups = ["default"]
|
||||||
marker = "sys_platform != \"emscripten\""
|
marker = "sys_platform != \"emscripten\""
|
||||||
files = [
|
files = [
|
||||||
{file = "wassima-2.1.4-py3-none-any.whl", hash = "sha256:26022272d4618aada0e26df2e79dbc0e034be8be9d6d5396512690ffc6a94e7b"},
|
{file = "wassima-2.1.5-py3-none-any.whl", hash = "sha256:53d9a56cf55fc969f8cdd1993f8dc69f857d1c32229fb9084dc2b267044b6dde"},
|
||||||
{file = "wassima-2.1.4.tar.gz", hash = "sha256:21bb77253bb8032c05393172c4d2df395f423b7e704538f3b2407f50c032034d"},
|
{file = "wassima-2.1.5.tar.gz", hash = "sha256:b02c1985e527c9539396cc85b909857d810dafaaa318a30266e585a0e7135f54"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ repos = [
|
|||||||
types_or = ["python", "pyi"],
|
types_or = ["python", "pyi"],
|
||||||
args = [],
|
args = [],
|
||||||
require_serial = true,
|
require_serial = true,
|
||||||
additional_dependencies = ["ruff==0.16.10"],
|
additional_dependencies = ["ruff==0.17.2"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id = "ruff-format",
|
id = "ruff-format",
|
||||||
@@ -32,7 +32,7 @@ repos = [
|
|||||||
language = "python",
|
language = "python",
|
||||||
types_or = ["python", "pyi"],
|
types_or = ["python", "pyi"],
|
||||||
require_serial = true,
|
require_serial = true,
|
||||||
additional_dependencies = ["ruff==0.16.10"],
|
additional_dependencies = ["ruff==0.17.2"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id = "basedpyright",
|
id = "basedpyright",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "plyngent"
|
name = "plyngent"
|
||||||
version = "0.11.0"
|
version = "0.13.0"
|
||||||
description = "LLM chat and agent toolkit"
|
description = "LLM chat and agent toolkit"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "HivertMoZara", email = "worldmozara@163.com" },
|
{ name = "HivertMoZara", email = "worldmozara@163.com" },
|
||||||
@@ -33,7 +33,7 @@ plyngent = "plyngent.cli:main"
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"basedpyright==1.40.2",
|
"basedpyright==1.40.2",
|
||||||
"ruff==0.16.10",
|
"ruff==0.17.2",
|
||||||
"pytest>=9.1.1",
|
"pytest>=9.1.1",
|
||||||
"pytest-asyncio>=1.4.0",
|
"pytest-asyncio>=1.4.0",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -58,6 +58,27 @@ class ToolOutput(Struct):
|
|||||||
return bool(self.images)
|
return bool(self.images)
|
||||||
|
|
||||||
|
|
||||||
|
def _without_refused_images(output: ToolOutput) -> ToolOutput:
|
||||||
|
"""Drop a picture the selected model cannot take, and say why.
|
||||||
|
|
||||||
|
``view_image`` and ``fetch_local`` refuse on their own, before any bytes are
|
||||||
|
read, so the model gets a clean error. This is the safety net for the rest —
|
||||||
|
an MCP server's image content, a plugin's ``ToolOutput`` — where the picture
|
||||||
|
would otherwise reach a model that answers ``[Unsupported Image]``, or a
|
||||||
|
gateway that hands it raw base64 (see ``doc/images.md``).
|
||||||
|
"""
|
||||||
|
if not output.images:
|
||||||
|
return output
|
||||||
|
from plyngent.tools.workspace import image_input_refusal
|
||||||
|
|
||||||
|
refusal = image_input_refusal()
|
||||||
|
if refusal is None:
|
||||||
|
return output
|
||||||
|
note = refusal.removeprefix("error: ")
|
||||||
|
text = f"{output.text}\n(error: image dropped: {note})" if output.text else f"error: image dropped: {note}"
|
||||||
|
return ToolOutput(text=text)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ToolConfirmRequest:
|
class ToolConfirmRequest:
|
||||||
"""One call of a batch that needs a soft confirm before it may run."""
|
"""One call of a batch that needs a soft confirm before it may run."""
|
||||||
@@ -737,7 +758,7 @@ class ToolRegistry:
|
|||||||
if denied is not None:
|
if denied is not None:
|
||||||
return ToolOutput(text=denied)
|
return ToolOutput(text=denied)
|
||||||
result = await self._invoke(definition, args)
|
result = await self._invoke(definition, args)
|
||||||
return result if isinstance(result, ToolOutput) else ToolOutput(text=result)
|
return _without_refused_images(result if isinstance(result, ToolOutput) else ToolOutput(text=result))
|
||||||
|
|
||||||
# Bind host-provided state for tools that read contextvars (todo, workspace).
|
# Bind host-provided state for tools that read contextvars (todo, workspace).
|
||||||
# Tag enforcement remains gated by ``auto_bind_state``; binding is always useful
|
# Tag enforcement remains gated by ``auto_bind_state``; binding is always useful
|
||||||
|
|||||||
@@ -351,6 +351,7 @@ async def _run_chat( # noqa: C901, PLR0915 — chat orchestration
|
|||||||
# instance.workspace (no process bag).
|
# instance.workspace (no process bag).
|
||||||
state.instance_state.workspace.path_denylist = tuple(store.agent_config.path_denylist or ())
|
state.instance_state.workspace.path_denylist = tuple(store.agent_config.path_denylist or ())
|
||||||
state.instance_state.workspace.trash_enabled = store.trash_config.enabled
|
state.instance_state.workspace.trash_enabled = store.trash_config.enabled
|
||||||
|
state.sync_image_capability()
|
||||||
from plyngent.tools.net import set_ssrf_assume_public_cidrs
|
from plyngent.tools.net import set_ssrf_assume_public_cidrs
|
||||||
|
|
||||||
# Clash Fake-IP etc.: treat configured CIDRs as public for fetch_local SSRF.
|
# Clash Fake-IP etc.: treat configured CIDRs as public for fetch_local SSRF.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ if TYPE_CHECKING:
|
|||||||
_TOOL_RESULT_PREVIEW = 120
|
_TOOL_RESULT_PREVIEW = 120
|
||||||
_TOOL_ARGS_PREVIEW = 80
|
_TOOL_ARGS_PREVIEW = 80
|
||||||
_ASK_SUBJECT_PREVIEW = 60
|
_ASK_SUBJECT_PREVIEW = 60
|
||||||
|
_PTY_PAYLOAD_PREVIEW = 40
|
||||||
# ``run_argv_batch`` lists the commands it runs above its ``* Run batch `` row — one
|
# ``run_argv_batch`` lists the commands it runs above its ``* Run batch `` row — one
|
||||||
# line per command, a ``pipe_out`` chain joined into one — elided past this width.
|
# line per command, a ``pipe_out`` chain joined into one — elided past this width.
|
||||||
_RUN_BATCH_CMD_LIMIT = 80
|
_RUN_BATCH_CMD_LIMIT = 80
|
||||||
@@ -631,6 +632,35 @@ def _pty_session_line(verb: str, detail: Callable[[str, str], str]) -> PrettyLin
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pty_payload(text: str, limit: int) -> str:
|
||||||
|
"""A PTY payload as one quoted, escaped fragment, shortened to *limit*.
|
||||||
|
|
||||||
|
``repr`` keeps it on one line and makes what is invisible visible: a
|
||||||
|
trailing newline shows as ``\\n``, an ESC as ``\\x1b``, and a payload made
|
||||||
|
only of spaces can be told apart from an empty one.
|
||||||
|
"""
|
||||||
|
if len(text) <= limit:
|
||||||
|
return repr(text)
|
||||||
|
head = repr(text[:limit])
|
||||||
|
return f"{head[:-1]}…{head[-1]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _pty_write_prefix(verb: str) -> Callable[[str], str]:
|
||||||
|
"""``* PTY <verb> <session> '<payload>' ``: what was sent, escaped and cut.
|
||||||
|
|
||||||
|
The payload is the one thing about a write worth seeing; the outcome line
|
||||||
|
still carries the exact size. ``ask_into_pty`` never uses this — the
|
||||||
|
human's answer must not be shown.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def prefix(args_json: str) -> str:
|
||||||
|
payload = _json_str_arg(args_json, "data") or ""
|
||||||
|
shown = _pty_payload(payload, _PTY_PAYLOAD_PREVIEW) if payload else "?"
|
||||||
|
return _pretty_prefix(f"* PTY {verb} {_pty_session_label(args_json)} {shown} ")
|
||||||
|
|
||||||
|
return prefix
|
||||||
|
|
||||||
|
|
||||||
def _open_pty_prefix(args_json: str) -> str:
|
def _open_pty_prefix(args_json: str) -> str:
|
||||||
argv = _json_list_arg(args_json, "command")
|
argv = _json_list_arg(args_json, "command")
|
||||||
cmd = shlex.join(argv) if argv else "?"
|
cmd = shlex.join(argv) if argv else "?"
|
||||||
@@ -942,8 +972,8 @@ _PRETTY_LINES: dict[str, PrettyLine] = {
|
|||||||
"skill_edit": PrettyLine(prefix=_skill_edit_prefix, detail=_skill_edit_detail),
|
"skill_edit": PrettyLine(prefix=_skill_edit_prefix, detail=_skill_edit_detail),
|
||||||
"open_pty": PrettyLine(prefix=_open_pty_prefix, detail=_open_pty_detail),
|
"open_pty": PrettyLine(prefix=_open_pty_prefix, detail=_open_pty_detail),
|
||||||
"read_pty": _pty_session_line("Read", _read_pty_detail),
|
"read_pty": _pty_session_line("Read", _read_pty_detail),
|
||||||
"write_pty": _pty_session_line("Write", _pty_write_detail),
|
"write_pty": PrettyLine(prefix=_pty_write_prefix("Write"), detail=_pty_write_detail),
|
||||||
"write_pty_keys": _pty_session_line("Keys", _pty_write_detail),
|
"write_pty_keys": PrettyLine(prefix=_pty_write_prefix("Keys"), detail=_pty_write_detail),
|
||||||
"ask_into_pty": _pty_session_line("Ask", _pty_write_detail),
|
"ask_into_pty": _pty_session_line("Ask", _pty_write_detail),
|
||||||
"close_pty": _pty_session_line("Close", _close_pty_detail),
|
"close_pty": _pty_session_line("Close", _close_pty_detail),
|
||||||
"ask_user_line": _ask_line("", "question", _ask_detail),
|
"ask_user_line": _ask_line("", "question", _ask_detail),
|
||||||
|
|||||||
+35
-20
@@ -69,11 +69,33 @@ _CONTINUE_PROMPT = "Raise limit and continue? [Y/n/yyy] (yyy = stop asking this
|
|||||||
_CONTINUE_YES_TOKENS = frozenset({"", "y", "yes"})
|
_CONTINUE_YES_TOKENS = frozenset({"", "y", "yes"})
|
||||||
_CONTINUE_AUTO_TOKEN = "yyy"
|
_CONTINUE_AUTO_TOKEN = "yyy"
|
||||||
|
|
||||||
|
# The answers each prompt accepts, as notification buttons: the key is the
|
||||||
|
# token the terminal read takes, the label is what a human reads there.
|
||||||
|
_LIMIT_CHOICES: tuple[tuple[str, str], ...] = (
|
||||||
|
("y", "Continue"),
|
||||||
|
(_CONTINUE_AUTO_TOKEN, "Continue, stop asking"),
|
||||||
|
("n", "Stop here"),
|
||||||
|
)
|
||||||
|
|
||||||
# Batch confirm answers: ``y`` allows the round's calls, ``m`` reviews them one
|
# Batch confirm answers: ``y`` allows the round's calls, ``m`` reviews them one
|
||||||
# at a time through the per-call prompt.
|
# at a time through the per-call prompt.
|
||||||
_BATCH_YES_TOKENS = frozenset({"y", "yes"})
|
_BATCH_YES_TOKENS = frozenset({"y", "yes"})
|
||||||
_BATCH_MANUAL_TOKENS = frozenset({"m", "manual", "each"})
|
_BATCH_MANUAL_TOKENS = frozenset({"m", "manual", "each"})
|
||||||
|
|
||||||
|
_BATCH_CHOICES: tuple[tuple[str, str], ...] = (
|
||||||
|
("y", "Allow all"),
|
||||||
|
("m", "Review one by one"),
|
||||||
|
("n", "Deny"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Skill writes: ``o`` allows this change, ``s`` the whole directory for the rest
|
||||||
|
# of the chat.
|
||||||
|
_SKILL_CHOICES: tuple[tuple[str, str], ...] = (
|
||||||
|
("o", "Allow once"),
|
||||||
|
("s", "This chat"),
|
||||||
|
("n", "Deny"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _AutoContinue:
|
class _AutoContinue:
|
||||||
@@ -202,9 +224,17 @@ def _decide_continue_limit(reason: str) -> bool:
|
|||||||
backend = get_prompt_backend()
|
backend = get_prompt_backend()
|
||||||
if not backend.is_interactive():
|
if not backend.is_interactive():
|
||||||
return False
|
return False
|
||||||
with prompt_wait("limit", "limit reached — continue?", reason):
|
with prompt_wait("limit", "limit reached — continue?", reason, choices=_LIMIT_CHOICES) as wait:
|
||||||
_echo_continue_limit(reason)
|
_echo_continue_limit(reason)
|
||||||
token = backend.read_line(_CONTINUE_PROMPT, default="y").strip().lower()
|
source = _answer_source(wait)
|
||||||
|
if source is None:
|
||||||
|
token = backend.read_line(_CONTINUE_PROMPT, default="y").strip().lower()
|
||||||
|
else:
|
||||||
|
# Same read as the timed confirms: stdin or the notification,
|
||||||
|
# whichever answers first, with the terminal's default on silence.
|
||||||
|
token = (_read_answer(None, wait) or "").strip().lower()
|
||||||
|
if not token:
|
||||||
|
token = "y"
|
||||||
if token == _CONTINUE_AUTO_TOKEN:
|
if token == _CONTINUE_AUTO_TOKEN:
|
||||||
_auto_continue.turn = True
|
_auto_continue.turn = True
|
||||||
return True
|
return True
|
||||||
@@ -236,22 +266,7 @@ async def prompt_continue_limit_async(reason: str) -> bool:
|
|||||||
|
|
||||||
def _confirm_with(wait: PromptAnswerSource | None, prompt: str, *, default: bool) -> bool:
|
def _confirm_with(wait: PromptAnswerSource | None, prompt: str, *, default: bool) -> bool:
|
||||||
"""Yes/no, also taking an answer that arrived through a notification."""
|
"""Yes/no, also taking an answer that arrived through a notification."""
|
||||||
source = _answer_source(wait)
|
return confirm(prompt, default=default, 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:
|
def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool | str:
|
||||||
@@ -468,7 +483,7 @@ def prompt_skill_write_confirm(
|
|||||||
try:
|
try:
|
||||||
with (
|
with (
|
||||||
pause_task_cancel_for_prompt(),
|
pause_task_cancel_for_prompt(),
|
||||||
prompt_wait("skills", f"allow {action} in {skill_dir.name!r}?", detail) as wait,
|
prompt_wait("skills", f"allow {action} in {skill_dir.name!r}?", detail, choices=_SKILL_CHOICES) as wait,
|
||||||
):
|
):
|
||||||
backend = get_prompt_backend()
|
backend = get_prompt_backend()
|
||||||
if not backend.is_interactive():
|
if not backend.is_interactive():
|
||||||
@@ -606,7 +621,7 @@ def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchC
|
|||||||
# Non-interactive: deny, like the default of the single prompt.
|
# Non-interactive: deny, like the default of the single prompt.
|
||||||
return BatchConfirmDecision(allowed=False)
|
return BatchConfirmDecision(allowed=False)
|
||||||
names = ", ".join(request.name for request in requests)
|
names = ", ".join(request.name for request in requests)
|
||||||
with prompt_wait("batch", f"{len(requests)} tool calls need confirmation", names) as wait:
|
with prompt_wait("batch", f"{len(requests)} tool calls need confirmation", names, choices=_BATCH_CHOICES) as wait:
|
||||||
backend.echo()
|
backend.echo()
|
||||||
backend.secho(format_batch_confirm_box(requests), fg="yellow")
|
backend.secho(format_batch_confirm_box(requests), fg="yellow")
|
||||||
backend.secho("m = review them one call at a time", fg="bright_black")
|
backend.secho("m = review them one call at a time", fg="bright_black")
|
||||||
|
|||||||
+114
-25
@@ -3,8 +3,12 @@
|
|||||||
A prompt blocks the turn, so someone who switched to another window learns about
|
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
|
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
|
``grace_seconds`` — answering at the terminal stays silent — and, where the
|
||||||
platform allows it, offers the answer itself: allow / deny / review buttons for
|
platform allows it, offers the answer itself: the prompt's own answers as
|
||||||
a confirmation, a reply field for a question.
|
buttons (allow / deny / review for a confirmation), a reply field for a
|
||||||
|
question. A prompt that can be answered from the notification reads from both
|
||||||
|
surfaces from its first poll, since the announcement only appears once the
|
||||||
|
grace period is over. A prompt that follows an answer given on the notification
|
||||||
|
is announced at once: the human is already looking at it.
|
||||||
|
|
||||||
Two channels. The desktop one is the optional ``desktop-notifier`` extra
|
Two channels. The desktop one is the optional ``desktop-notifier`` extra
|
||||||
(``pip install plyngent[notify]``); the terminal one writes an OSC escape
|
(``pip install plyngent[notify]``); the terminal one writes an OSC escape
|
||||||
@@ -38,8 +42,17 @@ ALLOW = "allow"
|
|||||||
DENY = "deny"
|
DENY = "deny"
|
||||||
REVIEW = "review"
|
REVIEW = "review"
|
||||||
|
|
||||||
#: Kinds whose notification carries answer buttons (``ask`` gets a reply field).
|
#: Kinds whose notification carries an answer, and the capability it needs: a
|
||||||
_ANSWER_KINDS = frozenset({"confirm", "batch", "policy", "access", "skills"})
|
#: confirmation gets buttons, a question gets a reply field.
|
||||||
|
_ANSWER_CAPABILITY: dict[str, str] = {
|
||||||
|
"confirm": "buttons",
|
||||||
|
"batch": "buttons",
|
||||||
|
"policy": "buttons",
|
||||||
|
"access": "buttons",
|
||||||
|
"skills": "buttons",
|
||||||
|
"limit": "buttons",
|
||||||
|
"ask": "reply",
|
||||||
|
}
|
||||||
|
|
||||||
_BUTTONS: tuple[tuple[str, str], ...] = (
|
_BUTTONS: tuple[tuple[str, str], ...] = (
|
||||||
(ALLOW, "Allow once"),
|
(ALLOW, "Allow once"),
|
||||||
@@ -50,6 +63,14 @@ _BUTTONS: tuple[tuple[str, str], ...] = (
|
|||||||
_MAX_TITLE = 80
|
_MAX_TITLE = 80
|
||||||
_MAX_MESSAGE = 200
|
_MAX_MESSAGE = 200
|
||||||
|
|
||||||
|
#: Seconds a desktop notification may stay up; ``0`` is the freedesktop value
|
||||||
|
#: for "until dismissed" (``-1`` would hand the notification server's default
|
||||||
|
#: back, which is a few seconds — after that the notification collapses into
|
||||||
|
#: the tray, where nobody waiting on a prompt looks). Urgency stays at the
|
||||||
|
#: library default: ``critical`` keeps the notification up just the same, but
|
||||||
|
#: paints it as urgent and asks the platform's Focus mode to let it through.
|
||||||
|
_NO_EXPIRY = 0
|
||||||
|
|
||||||
|
|
||||||
def _clean(text: str, limit: int) -> str:
|
def _clean(text: str, limit: int) -> str:
|
||||||
"""One line, no control characters, truncated to *limit* characters."""
|
"""One line, no control characters, truncated to *limit* characters."""
|
||||||
@@ -161,6 +182,11 @@ class DesktopNotifierChannel:
|
|||||||
send(
|
send(
|
||||||
title=request.title,
|
title=request.title,
|
||||||
message=request.message,
|
message=request.message,
|
||||||
|
# A prompt holds the turn until it is answered, so the
|
||||||
|
# notification must still be on screen when the human looks
|
||||||
|
# back. Dismissing is the prompt's own job once it is
|
||||||
|
# answered, so nothing lingers.
|
||||||
|
timeout=_NO_EXPIRY,
|
||||||
buttons=buttons,
|
buttons=buttons,
|
||||||
reply_field=reply,
|
reply_field=reply,
|
||||||
)
|
)
|
||||||
@@ -208,6 +234,7 @@ def load_desktop_channel() -> DesktopChannel | None:
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
_OSC9 = "\x1b]9;{title}: {message}\x07"
|
_OSC9 = "\x1b]9;{title}: {message}\x07"
|
||||||
|
_OSC9_TITLE_ONLY = "\x1b]9;{title}\x07"
|
||||||
_OSC777 = "\x1b]777;notify;{title};{message}\x07"
|
_OSC777 = "\x1b]777;notify;{title};{message}\x07"
|
||||||
_BELL = "\a"
|
_BELL = "\a"
|
||||||
#: VTE (GNOME Terminal, Tilix, Terminator, …) speaks OSC 777 from this version on.
|
#: VTE (GNOME Terminal, Tilix, Terminator, …) speaks OSC 777 from this version on.
|
||||||
@@ -258,7 +285,9 @@ def terminal_sequence(protocol: str, title: str, message: str, *, multiplexer: s
|
|||||||
safe_title = _clean(title, _MAX_TITLE)
|
safe_title = _clean(title, _MAX_TITLE)
|
||||||
safe_message = _clean(message, _MAX_MESSAGE)
|
safe_message = _clean(message, _MAX_MESSAGE)
|
||||||
if protocol == "osc9":
|
if protocol == "osc9":
|
||||||
seq = _OSC9.format(title=safe_title, message=safe_message)
|
# OSC 9 joins the two with a colon, which would dangle on a body-less one.
|
||||||
|
template = _OSC9 if safe_message else _OSC9_TITLE_ONLY
|
||||||
|
seq = template.format(title=safe_title, message=safe_message)
|
||||||
else:
|
else:
|
||||||
seq = _OSC777.format(title=safe_title, message=safe_message)
|
seq = _OSC777.format(title=safe_title, message=safe_message)
|
||||||
if multiplexer == "tmux":
|
if multiplexer == "tmux":
|
||||||
@@ -312,14 +341,20 @@ class WaitHandle:
|
|||||||
notifier: WaitNotifier
|
notifier: WaitNotifier
|
||||||
wait: PromptWait
|
wait: PromptWait
|
||||||
identifier: str | None
|
identifier: str | None
|
||||||
#: Whether the notification offered answers, so a read may poll for them.
|
#: Whether an answer may arrive from the notification, so a read polls for
|
||||||
|
#: one from the first moment it blocks — the notification itself is only
|
||||||
|
#: shown after the grace period.
|
||||||
supports_actions: bool
|
supports_actions: bool
|
||||||
|
#: Whether this prompt continues an exchange the human answered on the
|
||||||
|
#: notification: it is then announced at once, and not throttled.
|
||||||
|
handoff: bool
|
||||||
|
|
||||||
def __init__(self, notifier: WaitNotifier, wait: PromptWait) -> None:
|
def __init__(self, notifier: WaitNotifier, wait: PromptWait) -> None:
|
||||||
self.notifier = notifier
|
self.notifier = notifier
|
||||||
self.wait = wait
|
self.wait = wait
|
||||||
self.identifier = None
|
self.identifier = None
|
||||||
self.supports_actions = False
|
self.supports_actions = notifier.answer_capability(wait.kind) is not None
|
||||||
|
self.handoff = notifier.take_handoff()
|
||||||
self._answers: SimpleQueue[str] = SimpleQueue()
|
self._answers: SimpleQueue[str] = SimpleQueue()
|
||||||
self._timer: threading.Timer | None = None
|
self._timer: threading.Timer | None = None
|
||||||
self._closed: bool = False
|
self._closed: bool = False
|
||||||
@@ -339,10 +374,14 @@ class WaitHandle:
|
|||||||
# "review" means "I will answer in the terminal": withdraw and wait.
|
# "review" means "I will answer in the terminal": withdraw and wait.
|
||||||
self.notifier.dismiss(self)
|
self.notifier.dismiss(self)
|
||||||
return
|
return
|
||||||
|
# The human is at the notification now, so a prompt that follows this
|
||||||
|
# answer is announced at once instead of waiting out the grace period.
|
||||||
|
self.notifier.handoff()
|
||||||
self._answers.put(key)
|
self._answers.put(key)
|
||||||
|
|
||||||
def __enter__(self) -> Self:
|
def __enter__(self) -> Self:
|
||||||
timer = threading.Timer(self.notifier.grace_seconds, self._notify)
|
delay = 0.0 if self.handoff else self.notifier.grace_seconds
|
||||||
|
timer = threading.Timer(delay, self._notify)
|
||||||
timer.daemon = True
|
timer.daemon = True
|
||||||
self._timer = timer
|
self._timer = timer
|
||||||
timer.start()
|
timer.start()
|
||||||
@@ -361,7 +400,7 @@ class WaitHandle:
|
|||||||
self.notifier.dismiss(self)
|
self.notifier.dismiss(self)
|
||||||
|
|
||||||
def _notify(self) -> None:
|
def _notify(self) -> None:
|
||||||
if self._closed or not self.notifier.may_notify():
|
if self._closed or not self.notifier.may_notify(self):
|
||||||
return
|
return
|
||||||
if not self.notifier.notify_desktop(self):
|
if not self.notifier.notify_desktop(self):
|
||||||
self.notifier.notify_terminal(self.wait)
|
self.notifier.notify_terminal(self.wait)
|
||||||
@@ -377,10 +416,12 @@ class WaitNotifier:
|
|||||||
_config: NotifyConfig
|
_config: NotifyConfig
|
||||||
_resolve_desktop: bool
|
_resolve_desktop: bool
|
||||||
_desktop: DesktopChannel | None
|
_desktop: DesktopChannel | None
|
||||||
|
_resolve_lock: threading.Lock
|
||||||
_write: Callable[[str], None]
|
_write: Callable[[str], None]
|
||||||
_env: Mapping[str, str]
|
_env: Mapping[str, str]
|
||||||
_clock: Callable[[], float]
|
_clock: Callable[[], float]
|
||||||
_last_sent: float
|
_last_sent: float
|
||||||
|
_handoff: bool
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -398,10 +439,12 @@ class WaitNotifier:
|
|||||||
else:
|
else:
|
||||||
self._resolve_desktop = False
|
self._resolve_desktop = False
|
||||||
self._desktop = desktop
|
self._desktop = desktop
|
||||||
|
self._resolve_lock = threading.Lock()
|
||||||
self._write = write or write_terminal
|
self._write = write or write_terminal
|
||||||
self._env = os.environ if env is None else env
|
self._env = os.environ if env is None else env
|
||||||
self._clock = clock
|
self._clock = clock
|
||||||
self._last_sent = float("-inf")
|
self._last_sent = float("-inf")
|
||||||
|
self._handoff = False
|
||||||
|
|
||||||
# -- hook ------------------------------------------------------------- #
|
# -- hook ------------------------------------------------------------- #
|
||||||
|
|
||||||
@@ -412,33 +455,83 @@ class WaitNotifier:
|
|||||||
def grace_seconds(self) -> float:
|
def grace_seconds(self) -> float:
|
||||||
return self._config.grace_seconds
|
return self._config.grace_seconds
|
||||||
|
|
||||||
|
def handoff(self) -> None:
|
||||||
|
"""Note that the human is answering on the notification right now."""
|
||||||
|
self._handoff = True
|
||||||
|
|
||||||
|
def take_handoff(self) -> bool:
|
||||||
|
"""Consume the handoff: this prompt is part of the same exchange."""
|
||||||
|
handoff = self._handoff
|
||||||
|
self._handoff = False
|
||||||
|
return handoff
|
||||||
|
|
||||||
def _wants(self, channel: str) -> bool:
|
def _wants(self, channel: str) -> bool:
|
||||||
channels = self._config.channels
|
channels = self._config.channels
|
||||||
return channel in channels or "auto" in channels
|
return channel in channels or "auto" in channels
|
||||||
|
|
||||||
def may_notify(self) -> bool:
|
def may_notify(self, handle: WaitHandle) -> bool:
|
||||||
"""Whether a notification is worth sending right now."""
|
"""Whether this prompt is worth announcing right now."""
|
||||||
if not self._config.enabled or not get_prompt_backend().is_interactive():
|
if not self._config.enabled or not get_prompt_backend().is_interactive():
|
||||||
return False
|
return False
|
||||||
now = self._clock()
|
now = self._clock()
|
||||||
if now - self._last_sent < self._config.min_interval_seconds:
|
# A prompt the human reached through the notification is not a burst.
|
||||||
|
if not handle.handoff and now - self._last_sent < self._config.min_interval_seconds:
|
||||||
return False
|
return False
|
||||||
self._last_sent = now
|
self._last_sent = now
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _message(self, wait: PromptWait) -> str:
|
def _message(self, wait: PromptWait) -> str:
|
||||||
title = _clean(wait.title, _MAX_TITLE)
|
"""The body: the reason line, when the config allows it, else nothing.
|
||||||
|
|
||||||
|
Both channels show the title as their own field, so repeating it in the
|
||||||
|
body only makes the notification say the same thing twice.
|
||||||
|
"""
|
||||||
if self._config.include_reason and wait.detail:
|
if self._config.include_reason and wait.detail:
|
||||||
return f"{title}\n{_clean(wait.detail, _MAX_MESSAGE)}"
|
return _clean(wait.detail, _MAX_MESSAGE)
|
||||||
return title
|
return ""
|
||||||
|
|
||||||
|
def _buttons(self, wait: PromptWait) -> tuple[tuple[str, str], ...]:
|
||||||
|
"""The answers *wait* accepts, and always a way back to the terminal."""
|
||||||
|
if not wait.choices:
|
||||||
|
return _BUTTONS
|
||||||
|
return (*wait.choices, (REVIEW, "Go to terminal"))
|
||||||
|
|
||||||
def desktop_channel(self) -> DesktopChannel | None:
|
def desktop_channel(self) -> DesktopChannel | None:
|
||||||
"""The desktop surface, resolved once (importing the extra on demand)."""
|
"""The desktop surface, resolved once (importing the extra on demand)."""
|
||||||
if self._resolve_desktop:
|
if self._resolve_desktop:
|
||||||
self._desktop = load_desktop_channel()
|
with self._resolve_lock:
|
||||||
self._resolve_desktop = False
|
if self._resolve_desktop:
|
||||||
|
self._desktop = load_desktop_channel()
|
||||||
|
self._resolve_desktop = False
|
||||||
return self._desktop
|
return self._desktop
|
||||||
|
|
||||||
|
def answer_capability(self, kind: str) -> str | None:
|
||||||
|
"""The capability *kind*'s answer needs (``buttons`` / ``reply``), or None.
|
||||||
|
|
||||||
|
Answered before the notification is shown, so a prompt knows from the
|
||||||
|
start of its wait whether an answer may arrive.
|
||||||
|
"""
|
||||||
|
needed = _ANSWER_CAPABILITY.get(kind)
|
||||||
|
if needed is None or not self._config.actions or not self._wants("desktop"):
|
||||||
|
return None
|
||||||
|
channel = self.desktop_channel()
|
||||||
|
if channel is None or needed not in channel.capabilities():
|
||||||
|
return None
|
||||||
|
return needed
|
||||||
|
|
||||||
|
def warm(self) -> None:
|
||||||
|
"""Resolve the desktop channel off the caller's thread.
|
||||||
|
|
||||||
|
Probing the platform's capabilities costs a round trip to the
|
||||||
|
notification server, so the first prompt of a run must not pay for it
|
||||||
|
while the human is already waiting.
|
||||||
|
"""
|
||||||
|
if not self._resolve_desktop or not self._config.actions or not self._wants("desktop"):
|
||||||
|
return
|
||||||
|
if importlib.util.find_spec("desktop_notifier") is None:
|
||||||
|
return # no extra: the terminal channel is all there is
|
||||||
|
threading.Thread(target=self.desktop_channel, name="plyngent-notify-warm", daemon=True).start()
|
||||||
|
|
||||||
def notify_desktop(self, handle: WaitHandle) -> bool:
|
def notify_desktop(self, handle: WaitHandle) -> bool:
|
||||||
"""Show the desktop notification; False when it could not be shown."""
|
"""Show the desktop notification; False when it could not be shown."""
|
||||||
if not self._wants("desktop"):
|
if not self._wants("desktop"):
|
||||||
@@ -447,25 +540,20 @@ class WaitNotifier:
|
|||||||
if channel is None:
|
if channel is None:
|
||||||
return False
|
return False
|
||||||
wait = handle.wait
|
wait = handle.wait
|
||||||
capabilities = channel.capabilities()
|
capability = self.answer_capability(wait.kind)
|
||||||
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:
|
try:
|
||||||
handle.identifier = channel.send(
|
handle.identifier = channel.send(
|
||||||
DesktopRequest(
|
DesktopRequest(
|
||||||
title=_clean(wait.title, _MAX_TITLE),
|
title=_clean(wait.title, _MAX_TITLE),
|
||||||
message=self._message(wait),
|
message=self._message(wait),
|
||||||
buttons=buttons,
|
buttons=self._buttons(wait) if capability == "buttons" else (),
|
||||||
reply_field=reply,
|
reply_field=capability == "reply",
|
||||||
on_answer=handle.answer,
|
on_answer=handle.answer,
|
||||||
on_reply=handle.answer,
|
on_reply=handle.answer,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001 - a notification never breaks a prompt
|
except Exception: # noqa: BLE001 - a notification never breaks a prompt
|
||||||
return False
|
return False
|
||||||
handle.supports_actions = bool(buttons) or reply
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def notify_terminal(self, wait: PromptWait) -> None:
|
def notify_terminal(self, wait: PromptWait) -> None:
|
||||||
@@ -497,5 +585,6 @@ def install_notifier(config: NotifyConfig) -> WaitNotifier | None:
|
|||||||
set_prompt_wait_hook(None)
|
set_prompt_wait_hook(None)
|
||||||
return None
|
return None
|
||||||
notifier = WaitNotifier(config)
|
notifier = WaitNotifier(config)
|
||||||
|
notifier.warm()
|
||||||
set_prompt_wait_hook(notifier)
|
set_prompt_wait_hook(notifier)
|
||||||
return notifier
|
return notifier
|
||||||
|
|||||||
@@ -507,6 +507,7 @@ class ReplState:
|
|||||||
# Preserve live stream toggle if agent already exists.
|
# Preserve live stream toggle if agent already exists.
|
||||||
if hasattr(self, "agent"):
|
if hasattr(self, "agent"):
|
||||||
self.stream_enabled = self.agent.stream
|
self.stream_enabled = self.agent.stream
|
||||||
|
self.sync_image_capability()
|
||||||
self.client = create_client(self.provider, model=self.model, reasoning=self.reasoning)
|
self.client = create_client(self.provider, model=self.model, reasoning=self.reasoning)
|
||||||
self.agent = self._make_agent()
|
self.agent = self._make_agent()
|
||||||
# Restore history without re-marking already-stored messages as dirty.
|
# Restore history without re-marking already-stored messages as dirty.
|
||||||
@@ -517,6 +518,21 @@ class ReplState:
|
|||||||
if self._remote_models_key is not None and self._remote_models_key != self._models_cache_key():
|
if self._remote_models_key is not None and self._remote_models_key != self._models_cache_key():
|
||||||
self.invalidate_remote_models()
|
self.invalidate_remote_models()
|
||||||
|
|
||||||
|
def sync_image_capability(self) -> None:
|
||||||
|
"""Publish the selected model's ``image_in`` to the image-carrying tools.
|
||||||
|
|
||||||
|
Same verdict the ``@path`` mention gate uses: only a model declared with
|
||||||
|
``image_in = true`` takes pictures, so a text-only model (or one behind a
|
||||||
|
gateway that cannot read image blocks) is answered with an error instead
|
||||||
|
of a picture it could never describe.
|
||||||
|
"""
|
||||||
|
from plyngent.config.routing import model_config_for
|
||||||
|
|
||||||
|
model_config = model_config_for(self.provider, self.model)
|
||||||
|
policy = self.instance_state.workspace
|
||||||
|
policy.image_in = model_config is not None and bool(model_config.image_in)
|
||||||
|
policy.image_model = (self.provider_name, self.model)
|
||||||
|
|
||||||
def _models_cache_key(self) -> tuple[str, str]:
|
def _models_cache_key(self) -> tuple[str, str]:
|
||||||
# Remote model lists belong to the provider endpoint used for discovery,
|
# Remote model lists belong to the provider endpoint used for discovery,
|
||||||
# not model-level overrides (the model is not selected yet there).
|
# not model-level overrides (the model is not selected yet there).
|
||||||
|
|||||||
@@ -284,7 +284,8 @@ class NotifyConfig(Struct, omit_defaults=True):
|
|||||||
"""Desktop notifications shown while a prompt waits on the human.
|
"""Desktop notifications shown while a prompt waits on the human.
|
||||||
|
|
||||||
A prompt is announced only after ``grace_seconds`` of waiting, so answering
|
A prompt is announced only after ``grace_seconds`` of waiting, so answering
|
||||||
at the terminal stays silent; ``min_interval_seconds`` throttles bursts.
|
at the terminal stays silent; ``min_interval_seconds`` throttles bursts, and
|
||||||
|
a prompt that follows an answer given on the notification skips both.
|
||||||
``channels`` picks the surfaces: ``auto`` tries the desktop first and falls
|
``channels`` picks the surfaces: ``auto`` tries the desktop first and falls
|
||||||
back to the terminal, ``desktop`` / ``terminal`` pin one of them.
|
back to the terminal, ``desktop`` / ``terminal`` pin one of them.
|
||||||
|
|
||||||
|
|||||||
@@ -126,12 +126,16 @@ class PromptWait:
|
|||||||
``choose``, ``policy``, ``access``, ``skills``, ``limit``) so the host can
|
``choose``, ``policy``, ``access``, ``skills``, ``limit``) so the host can
|
||||||
tailor what it shows and which answers it offers; ``title`` is short and
|
tailor what it shows and which answers it offers; ``title`` is short and
|
||||||
safe to surface anywhere, while ``detail`` carries one optional line the
|
safe to surface anywhere, while ``detail`` carries one optional line the
|
||||||
caller has already redacted.
|
caller has already redacted. ``choices`` are the answers this particular
|
||||||
|
prompt accepts, as ``(key, label)`` pairs — a host that offers them sends
|
||||||
|
the key back, so the label can stay human and the key stay the token the
|
||||||
|
terminal read would accept. Empty means the host decides.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
kind: str
|
kind: str
|
||||||
title: str
|
title: str
|
||||||
detail: str = ""
|
detail: str = ""
|
||||||
|
choices: tuple[tuple[str, str], ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
@@ -140,7 +144,12 @@ class PromptAnswerSource(Protocol):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def supports_actions(self) -> bool:
|
def supports_actions(self) -> bool:
|
||||||
"""Whether :meth:`take` can answer this prompt at all."""
|
"""Whether an answer may arrive while the prompt waits.
|
||||||
|
|
||||||
|
A surface that will offer the answer says so before it has offered it —
|
||||||
|
the notification is only shown after the host's grace period — so a read
|
||||||
|
polls :meth:`take` from its first moment instead of deciding once.
|
||||||
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def take(self) -> str | None:
|
def take(self) -> str | None:
|
||||||
@@ -171,9 +180,17 @@ def get_prompt_wait_hook() -> PromptWaitHook | None:
|
|||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def prompt_wait(kind: str, title: str, detail: str = "") -> Generator[PromptAnswerSource | None]:
|
def prompt_wait(
|
||||||
|
kind: str,
|
||||||
|
title: str,
|
||||||
|
detail: str = "",
|
||||||
|
choices: tuple[tuple[str, str], ...] = (),
|
||||||
|
) -> Generator[PromptAnswerSource | None]:
|
||||||
"""Announce a blocking prompt to the host; yields its answer source (or None).
|
"""Announce a blocking prompt to the host; yields its answer source (or None).
|
||||||
|
|
||||||
|
``choices`` says which answers this prompt accepts (see :class:`PromptWait`);
|
||||||
|
a host that shows them sends the key back as the answer.
|
||||||
|
|
||||||
Only the outermost prompt of a nested read is announced: a confirm that asks
|
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
|
a follow-up question while it is already on screen must not notify — and
|
||||||
interrupt — a second time.
|
interrupt — a second time.
|
||||||
@@ -184,7 +201,7 @@ def prompt_wait(kind: str, title: str, detail: str = "") -> Generator[PromptAnsw
|
|||||||
return
|
return
|
||||||
token = _wait_depth.set(_wait_depth.get() + 1)
|
token = _wait_depth.set(_wait_depth.get() + 1)
|
||||||
try:
|
try:
|
||||||
with hook(PromptWait(kind, title, detail)) as source:
|
with hook(PromptWait(kind, title, detail, choices)) as source:
|
||||||
yield source
|
yield source
|
||||||
finally:
|
finally:
|
||||||
_wait_depth.reset(token)
|
_wait_depth.reset(token)
|
||||||
@@ -619,8 +636,26 @@ def choose(
|
|||||||
backend.echo("Invalid choice; pick a listed option.")
|
backend.echo("Invalid choice; pick a listed option.")
|
||||||
|
|
||||||
|
|
||||||
def confirm(prompt: str, *, default: bool = False) -> bool:
|
#: Answers a surface spells out on top of the ``y`` a confirm already accepts.
|
||||||
"""Yes/no confirm via the active backend.
|
_YES = frozenset({"allow", "y", "yes"})
|
||||||
|
|
||||||
|
|
||||||
|
def _confirm_from_source(prompt: str, source: PromptAnswerSource, *, default: bool) -> bool:
|
||||||
|
"""Yes/no for a prompt a surface may answer, reading the terminal as well."""
|
||||||
|
hint = "[Y/n]" if default else "[y/N]"
|
||||||
|
try:
|
||||||
|
answer = _read_with_source(f"{prompt} {hint}", source, default="")
|
||||||
|
except (KeyboardInterrupt, EOFError) as exc:
|
||||||
|
msg = "prompt cancelled"
|
||||||
|
raise NonInteractiveError(msg) from exc
|
||||||
|
token = answer.strip().lower()
|
||||||
|
if not token:
|
||||||
|
return default
|
||||||
|
return token in _YES
|
||||||
|
|
||||||
|
|
||||||
|
def confirm(prompt: str, *, default: bool = False, source: PromptAnswerSource | None = None) -> bool:
|
||||||
|
"""Yes/no confirm via the active backend, or from *source* when given.
|
||||||
|
|
||||||
Non-interactive backends return ``default`` without blocking.
|
Non-interactive backends return ``default`` without blocking.
|
||||||
Cancel (Ctrl+C / EOF) raises :class:`NonInteractiveError`.
|
Cancel (Ctrl+C / EOF) raises :class:`NonInteractiveError`.
|
||||||
@@ -628,8 +663,13 @@ def confirm(prompt: str, *, default: bool = False) -> bool:
|
|||||||
backend = get_prompt_backend()
|
backend = get_prompt_backend()
|
||||||
if not backend.is_interactive():
|
if not backend.is_interactive():
|
||||||
return default
|
return default
|
||||||
|
if source is not None:
|
||||||
|
return _confirm_from_source(prompt, source, default=default)
|
||||||
backend.echo()
|
backend.echo()
|
||||||
with prompt_wait("confirm", prompt):
|
with prompt_wait("confirm", prompt) as wait:
|
||||||
|
offered = wait if wait is not None and wait.supports_actions else None
|
||||||
|
if offered is not None:
|
||||||
|
return _confirm_from_source(prompt, offered, default=default)
|
||||||
return backend.confirm(prompt, default=default)
|
return backend.confirm(prompt, default=default)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ from .workspace import get_trash_enabled as get_trash_enabled
|
|||||||
from .workspace import get_workspace_root as get_workspace_root
|
from .workspace import get_workspace_root as get_workspace_root
|
||||||
from .workspace import grant_policy_command as grant_policy_command
|
from .workspace import grant_policy_command as grant_policy_command
|
||||||
from .workspace import granted_access_mode as granted_access_mode
|
from .workspace import granted_access_mode as granted_access_mode
|
||||||
|
from .workspace import image_input_refusal as image_input_refusal
|
||||||
from .workspace import list_workspace_allowlist as list_workspace_allowlist
|
from .workspace import list_workspace_allowlist as list_workspace_allowlist
|
||||||
from .workspace import match_command_denylist as match_command_denylist
|
from .workspace import match_command_denylist as match_command_denylist
|
||||||
from .workspace import mode_covers as mode_covers
|
from .workspace import mode_covers as mode_covers
|
||||||
@@ -113,6 +114,7 @@ from .workspace import pop_owned_temporary_workspaces as pop_owned_temporary_wor
|
|||||||
from .workspace import remove_workspace_allowlist as remove_workspace_allowlist
|
from .workspace import remove_workspace_allowlist as remove_workspace_allowlist
|
||||||
from .workspace import resolve_path as resolve_path
|
from .workspace import resolve_path as resolve_path
|
||||||
from .workspace import set_command_denylist as set_command_denylist
|
from .workspace import set_command_denylist as set_command_denylist
|
||||||
|
from .workspace import set_image_in as set_image_in
|
||||||
from .workspace import set_path_denylist as set_path_denylist
|
from .workspace import set_path_denylist as set_path_denylist
|
||||||
from .workspace import set_policy_confirm_hook as set_policy_confirm_hook
|
from .workspace import set_policy_confirm_hook as set_policy_confirm_hook
|
||||||
from .workspace import set_policy_confirm_timeout as set_policy_confirm_timeout
|
from .workspace import set_policy_confirm_timeout as set_policy_confirm_timeout
|
||||||
|
|||||||
+315
-157
@@ -9,6 +9,7 @@ program — further in.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import getopt
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -19,6 +20,7 @@ if TYPE_CHECKING:
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"ESCALATION_PROGRAMS",
|
"ESCALATION_PROGRAMS",
|
||||||
"CommandString",
|
"CommandString",
|
||||||
|
"RemoteTarget",
|
||||||
"UnwrappedCommand",
|
"UnwrappedCommand",
|
||||||
"basename",
|
"basename",
|
||||||
"dash_c_code",
|
"dash_c_code",
|
||||||
@@ -27,34 +29,9 @@ __all__ = [
|
|||||||
"unwrap_command",
|
"unwrap_command",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Wrappers that run the program named on their own command line.
|
|
||||||
_ENV_WRAPPERS: frozenset[str] = frozenset(
|
|
||||||
{
|
|
||||||
"command",
|
|
||||||
"doas",
|
|
||||||
"env",
|
|
||||||
"exec",
|
|
||||||
"ionice",
|
|
||||||
"nice",
|
|
||||||
"nohup",
|
|
||||||
"pkexec",
|
|
||||||
"setsid",
|
|
||||||
"stdbuf",
|
|
||||||
"su",
|
|
||||||
"sudo",
|
|
||||||
"time",
|
|
||||||
"timeout",
|
|
||||||
"watch",
|
|
||||||
"xargs",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Detached runners: review-worthy whatever program they wrap.
|
# Detached runners: review-worthy whatever program they wrap.
|
||||||
_SELF_REVIEW: frozenset[str] = frozenset({"nohup"})
|
_SELF_REVIEW: frozenset[str] = frozenset({"nohup"})
|
||||||
|
|
||||||
# Wrappers that take a bare duration of their own (``timeout 5 cmd``).
|
|
||||||
_DURATION_WRAPPERS: frozenset[str] = frozenset({"timeout"})
|
|
||||||
|
|
||||||
# ``<launcher> run <program> …``: task runners and package managers that launch
|
# ``<launcher> run <program> …``: task runners and package managers that launch
|
||||||
# a program in their environment. A package-manager script (``npm run build``)
|
# a program in their environment. A package-manager script (``npm run build``)
|
||||||
# names a script rather than a program; the launcher basename is still checked.
|
# names a script rather than a program; the launcher basename is still checked.
|
||||||
@@ -118,60 +95,186 @@ _ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=")
|
|||||||
# A bare duration, as ``timeout`` takes: ``5``, ``1.5``, ``30s``.
|
# A bare duration, as ``timeout`` takes: ``5``, ``1.5``, ``30s``.
|
||||||
_DURATION = re.compile(r"[0-9]+(?:\.[0-9]+)?[smhd]?")
|
_DURATION = re.compile(r"[0-9]+(?:\.[0-9]+)?[smhd]?")
|
||||||
|
|
||||||
# Options that consume the next token, so ``sudo -u root python`` still finds
|
# Every wrapper's own options, spelled the way ``getopt`` reads them: a short
|
||||||
# ``python``; jammed forms (``-n10``, ``--with=pkg``) need no entry, and unknown
|
# option with ``:`` (``u:``) takes a value, a bare one is a flag, and a long
|
||||||
# options are skipped bare. List value-taking options only: an entry for a flag
|
# option ending in ``=`` takes one. The flags matter as much as the values — a
|
||||||
# eats the program behind it, which hid ``rm`` from ``exec -c rm -rf /`` and
|
# value-only list let a flag eat the program behind it (``exec -c rm -rf /``
|
||||||
# judged ``exec -c git -c … init`` as ``init``. An option with an *optional*
|
# resolved to the ``-c``, hiding ``rm``), and an unknown option is no longer
|
||||||
# value (``xargs -e``, ``watch -d``) needs no entry either — its bare form runs
|
# guessed at (see `_parse_options`).
|
||||||
# the program, and the jammed form carries the value in the same token.
|
|
||||||
_VALUE_OPTS: dict[str, frozenset[str]] = {
|
|
||||||
"doas": frozenset({"-u", "--user"}),
|
@dataclass(frozen=True)
|
||||||
"env": frozenset({"-C", "-S", "-u", "--chdir", "--split-string", "--unset"}),
|
class _Spec:
|
||||||
"exec": frozenset({"-a"}),
|
"""How one wrapper is invoked, so the program behind it can be found."""
|
||||||
"ionice": frozenset({"-c", "-n", "-p", "-P", "-u"}),
|
|
||||||
"nice": frozenset({"-n", "--adjustment"}),
|
short: str = ""
|
||||||
"pkexec": frozenset({"--user"}),
|
long: tuple[str, ...] = ()
|
||||||
"stdbuf": frozenset({"-e", "-i", "-o"}),
|
#: Options whose value is a command line the wrapper runs (``env -S``).
|
||||||
"su": frozenset({"-c", "-g", "-G", "-s", "-u", "-w", "--command", "--group", "--session-command", "--shell"}),
|
command_opts: frozenset[str] = frozenset()
|
||||||
"sudo": frozenset(
|
#: Takes a bare duration of its own before its command (``timeout 5 cmd``).
|
||||||
{
|
duration: bool = False
|
||||||
"-C",
|
#: ``<launcher> run <program>``: only the ``run`` form wraps a program.
|
||||||
"-D",
|
launcher: bool = False
|
||||||
"-g",
|
#: Runs its command on another machine (``ssh`` / ``mosh``).
|
||||||
"-h",
|
remote: bool = False
|
||||||
"-p",
|
|
||||||
"-r",
|
|
||||||
"-R",
|
_WRAPPERS: dict[str, _Spec] = {
|
||||||
"-t",
|
"command": _Spec(short="pvV"),
|
||||||
"-T",
|
"doas": _Spec(short="C:nsu:", long=("user=",)),
|
||||||
"-u",
|
# ``env -S`` / ``su -c`` carry a *command line*: the value is code the review
|
||||||
"-U",
|
# must surface (`_Spec.command_opts`), not an argument.
|
||||||
"--close-from",
|
"env": _Spec(
|
||||||
"--command-timeout",
|
short="0iC:u:S:v",
|
||||||
"--group",
|
long=("chdir=", "debug", "ignore-environment", "null", "split-string=", "unset="),
|
||||||
"--host",
|
command_opts=frozenset({"--split-string", "-S"}),
|
||||||
"--other-user",
|
),
|
||||||
"--prompt",
|
"exec": _Spec(short="cla:"),
|
||||||
"--role",
|
"ionice": _Spec(short="c:n:p:P:u:thV"),
|
||||||
"--type",
|
"nice": _Spec(short="n:", long=("adjustment=",)),
|
||||||
"--user",
|
"nohup": _Spec(),
|
||||||
}
|
"pkexec": _Spec(long=("disable-internal-agent", "help", "user=", "version")),
|
||||||
|
"setsid": _Spec(short="cfwV"),
|
||||||
|
"stdbuf": _Spec(short="e:i:o:", long=("error=", "input=", "output=")),
|
||||||
|
"su": _Spec(
|
||||||
|
short="c:g:G:lmps:u:w:",
|
||||||
|
long=(
|
||||||
|
"command=",
|
||||||
|
"group=",
|
||||||
|
"login",
|
||||||
|
"preserve-environment",
|
||||||
|
"session-command=",
|
||||||
|
"shell=",
|
||||||
|
"whitelist-environment=",
|
||||||
|
),
|
||||||
|
command_opts=frozenset({"--command", "--session-command", "-c"}),
|
||||||
|
),
|
||||||
|
"sudo": _Spec(
|
||||||
|
short="AbEHiKknPSsVvLlC:D:g:h:p:r:R:t:T:u:U:",
|
||||||
|
long=(
|
||||||
|
"askpass",
|
||||||
|
"background",
|
||||||
|
"chdir=",
|
||||||
|
"close-from=",
|
||||||
|
"command-timeout=",
|
||||||
|
"edit",
|
||||||
|
"group=",
|
||||||
|
"host=",
|
||||||
|
"list",
|
||||||
|
"login",
|
||||||
|
"non-interactive",
|
||||||
|
"other-user=",
|
||||||
|
"preserve-env",
|
||||||
|
"prompt=",
|
||||||
|
"remove-timestamp",
|
||||||
|
"reset-timestamp",
|
||||||
|
"role=",
|
||||||
|
"set-home",
|
||||||
|
"shell",
|
||||||
|
"stdin",
|
||||||
|
"type=",
|
||||||
|
"user=",
|
||||||
|
"validate",
|
||||||
|
"version",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"time": _Spec(short="af:o:pq", long=("append", "format=", "output=", "portability", "quiet", "verbose")),
|
||||||
|
"timeout": _Spec(
|
||||||
|
short="k:s:v",
|
||||||
|
long=("foreground", "kill-after=", "preserve-status", "signal="),
|
||||||
|
duration=True,
|
||||||
|
),
|
||||||
|
"watch": _Spec(
|
||||||
|
short="bcdeghn:prtvx",
|
||||||
|
long=(
|
||||||
|
"beep",
|
||||||
|
"chgexit",
|
||||||
|
"color",
|
||||||
|
"differences",
|
||||||
|
"errexit",
|
||||||
|
"exec",
|
||||||
|
"interval=",
|
||||||
|
"no-rerun",
|
||||||
|
"no-title",
|
||||||
|
"precise",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"xargs": _Spec(
|
||||||
|
short="0a:d:eE:iI:L:ln:P:prs:txo",
|
||||||
|
long=(
|
||||||
|
"arg-file=",
|
||||||
|
"delimiter=",
|
||||||
|
"eof=",
|
||||||
|
"interactive",
|
||||||
|
"max-args=",
|
||||||
|
"max-chars=",
|
||||||
|
"max-lines=",
|
||||||
|
"max-procs=",
|
||||||
|
"no-run-if-empty",
|
||||||
|
"null",
|
||||||
|
"open-tty",
|
||||||
|
"replace=",
|
||||||
|
"show-limits",
|
||||||
|
"verbose",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
"time": frozenset({"-f", "-o", "--format", "--output"}),
|
|
||||||
"timeout": frozenset({"-k", "-s", "--kill-after", "--signal"}),
|
|
||||||
"watch": frozenset({"-n"}),
|
|
||||||
"xargs": frozenset({"-a", "-d", "-E", "-I", "-L", "-n", "-P", "-s"}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Options whose value is a *command line* the wrapper will run, so the value is
|
# ``<launcher> run <program> …``: the option sets of the launchers below differ,
|
||||||
# code rather than an argument: ``env -S "rm -rf /"`` and ``su -c "…"``. An
|
# so one union stands in for all of them — a value option is the only kind that
|
||||||
# interpreter's own ``-c`` is the same idea, but there the interpreter is the
|
# can swallow the program, and the common flags are listed to keep them quiet.
|
||||||
# program under review; here a wrapper hides the command, so the review has to
|
_LAUNCHER_SPEC = _Spec(
|
||||||
# surface it (:func:`plyngent.tools.danger.classify_danger`).
|
short="C:e:F:G:m:n:p:s:w:",
|
||||||
_COMMAND_STRING_OPTS: dict[str, frozenset[str]] = {
|
long=(
|
||||||
"env": frozenset({"-S", "--split-string"}),
|
"all-extras",
|
||||||
"su": frozenset({"-c", "--command", "--session-command"}),
|
"all-groups",
|
||||||
|
"all-packages",
|
||||||
|
"config=",
|
||||||
|
"constraint=",
|
||||||
|
"default-index=",
|
||||||
|
"directory=",
|
||||||
|
"env=",
|
||||||
|
"env-file=",
|
||||||
|
"extra=",
|
||||||
|
"extras=",
|
||||||
|
"file=",
|
||||||
|
"frozen",
|
||||||
|
"group=",
|
||||||
|
"index=",
|
||||||
|
"locked",
|
||||||
|
"module=",
|
||||||
|
"name=",
|
||||||
|
"no-project",
|
||||||
|
"no-sync",
|
||||||
|
"offline",
|
||||||
|
"overrides=",
|
||||||
|
"package=",
|
||||||
|
"prefix=",
|
||||||
|
"project=",
|
||||||
|
"python=",
|
||||||
|
"python-version=",
|
||||||
|
"quiet",
|
||||||
|
"refresh",
|
||||||
|
"script=",
|
||||||
|
"verbose",
|
||||||
|
"with=",
|
||||||
|
"without=",
|
||||||
|
),
|
||||||
|
launcher=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ``ssh`` / ``mosh``: the program under review is the one after the destination,
|
||||||
|
# which is the first token that is not one of their options.
|
||||||
|
_REMOTE_WRAPPERS: dict[str, _Spec] = {
|
||||||
|
"mosh": _Spec(
|
||||||
|
short="46hP:p:v",
|
||||||
|
long=("client-port=", "family=", "help", "no-ssh-pty", "port=", "predict=", "server=", "ssh="),
|
||||||
|
remote=True,
|
||||||
|
),
|
||||||
|
"ssh": _Spec(
|
||||||
|
short="46AaCfgGKkMNnqSTtVvXxYysB:b:c:D:E:e:F:I:i:J:L:l:m:O:o:P:p:Q:R:S:W:w:",
|
||||||
|
remote=True,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Launcher options that consume a value (``uv run --with rich python``).
|
# Launcher options that consume a value (``uv run --with rich python``).
|
||||||
@@ -226,6 +329,18 @@ class CommandString:
|
|||||||
return f"{self.wrapper} {self.option}"
|
return f"{self.wrapper} {self.option}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RemoteTarget:
|
||||||
|
"""The hop a remote wrapper (``ssh`` / ``mosh``) sends its command to."""
|
||||||
|
|
||||||
|
wrapper: str
|
||||||
|
host: str
|
||||||
|
#: ``-o BatchMode=yes``: the client never prompts, so the command runs.
|
||||||
|
batch: bool = False
|
||||||
|
#: No command followed: the client opens a shell whose input is not scanned.
|
||||||
|
shell: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class UnwrappedCommand:
|
class UnwrappedCommand:
|
||||||
"""A command plus the program it really runs, past wrappers and launchers."""
|
"""A command plus the program it really runs, past wrappers and launchers."""
|
||||||
@@ -236,6 +351,10 @@ class UnwrappedCommand:
|
|||||||
base: str
|
base: str
|
||||||
self_review: tuple[str, ...]
|
self_review: tuple[str, ...]
|
||||||
command_strings: tuple[CommandString, ...] = ()
|
command_strings: tuple[CommandString, ...] = ()
|
||||||
|
#: The remote hop, when the command runs on another machine.
|
||||||
|
remote: RemoteTarget | None = None
|
||||||
|
#: An option the wrapper's spec does not know: the program behind it is a guess.
|
||||||
|
unparsed: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def command(self) -> tuple[str, ...]:
|
def command(self) -> tuple[str, ...]:
|
||||||
@@ -287,20 +406,39 @@ def unwrap_command(argv: Sequence[str]) -> UnwrappedCommand:
|
|||||||
"""Resolve *argv* to the program behind any wrappers and ``run`` launchers.
|
"""Resolve *argv* to the program behind any wrappers and ``run`` launchers.
|
||||||
|
|
||||||
Wrappers are unwrapped outer → inner as long as a program still follows
|
Wrappers are unwrapped outer → inner as long as a program still follows
|
||||||
them; their own options and ``FOO=bar`` assignments are skipped, and an
|
them; their own options and ``FOO=bar`` assignments are read by ``getopt``
|
||||||
option that carries a command line is collected in ``command_strings``.
|
(:func:`_parse_options`), and an option that carries a command line is
|
||||||
|
collected in ``command_strings``.
|
||||||
|
|
||||||
|
``ssh`` / ``mosh`` are unwrapped the same way — the program under review is
|
||||||
|
the one after the destination (``ssh prod rm -rf /`` runs ``rm``), and the
|
||||||
|
hop is reported in :attr:`UnwrappedCommand.remote`. A client with no command
|
||||||
|
opens a remote shell (``remote.shell``); nothing behind it can be resolved
|
||||||
|
here.
|
||||||
|
|
||||||
|
An option the wrapper's spec does not know stops the unwrap at that wrapper
|
||||||
|
and is reported in :attr:`UnwrappedCommand.unparsed`: the program behind it
|
||||||
|
is unknown, so the review asks instead of guessing (the wrapper itself is
|
||||||
|
still judged, so ``sudo <new flag> rm -rf /`` keeps its ``sudo`` prompt).
|
||||||
"""
|
"""
|
||||||
parts = list(argv)
|
parts = list(argv)
|
||||||
wrappers: list[str] = []
|
wrappers: list[str] = []
|
||||||
command_strings: list[CommandString] = []
|
command_strings: list[CommandString] = []
|
||||||
|
remote: RemoteTarget | None = None
|
||||||
|
unparsed = ""
|
||||||
index = 0
|
index = 0
|
||||||
while index < len(parts):
|
while index < len(parts):
|
||||||
name = basename(parts[index])
|
name = basename(parts[index])
|
||||||
nxt = _skip_wrapper(name, parts, index + 1, command_strings=command_strings)
|
step = _step(name, parts, index + 1, command_strings=command_strings)
|
||||||
if nxt is None:
|
if step.unparsed:
|
||||||
|
unparsed = f"{name} {step.unparsed}"
|
||||||
|
break
|
||||||
|
if step.remote is not None:
|
||||||
|
remote = step.remote
|
||||||
|
if step.index is None:
|
||||||
break
|
break
|
||||||
wrappers.append(name)
|
wrappers.append(name)
|
||||||
index = nxt
|
index = step.index
|
||||||
base = basename(parts[index]) if index < len(parts) else ""
|
base = basename(parts[index]) if index < len(parts) else ""
|
||||||
chain = dict.fromkeys((*wrappers, base))
|
chain = dict.fromkeys((*wrappers, base))
|
||||||
return UnwrappedCommand(
|
return UnwrappedCommand(
|
||||||
@@ -310,88 +448,108 @@ def unwrap_command(argv: Sequence[str]) -> UnwrappedCommand:
|
|||||||
base=base,
|
base=base,
|
||||||
self_review=tuple(name for name in chain if name in _SELF_REVIEW),
|
self_review=tuple(name for name in chain if name in _SELF_REVIEW),
|
||||||
command_strings=tuple(command_strings),
|
command_strings=tuple(command_strings),
|
||||||
|
remote=remote,
|
||||||
|
unparsed=unparsed,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _skip_wrapper(
|
@dataclass(frozen=True)
|
||||||
name: str,
|
class _Parsed:
|
||||||
parts: list[str],
|
"""A wrapper's own options, where its command starts, and what it did not know."""
|
||||||
start: int,
|
|
||||||
*,
|
options: tuple[tuple[str, str], ...] = ()
|
||||||
command_strings: list[CommandString],
|
index: int = 0
|
||||||
) -> int | None:
|
unparsed: str = ""
|
||||||
"""Index of the program *name* wraps, or ``None`` when it is the whole command."""
|
|
||||||
if name in _ENV_WRAPPERS:
|
|
||||||
nxt = _skip_options(
|
|
||||||
parts,
|
|
||||||
start,
|
|
||||||
name=name,
|
|
||||||
value_opts=_VALUE_OPTS.get(name, frozenset()),
|
|
||||||
leading_duration=name in _DURATION_WRAPPERS,
|
|
||||||
command_strings=command_strings,
|
|
||||||
)
|
|
||||||
return nxt if nxt < len(parts) else None
|
|
||||||
if name in _RUN_LAUNCHERS and start < len(parts) and parts[start] == "run":
|
|
||||||
nxt = _skip_options(
|
|
||||||
parts,
|
|
||||||
start + 1,
|
|
||||||
name=name,
|
|
||||||
value_opts=_RUN_VALUE_OPTS,
|
|
||||||
leading_duration=False,
|
|
||||||
command_strings=command_strings,
|
|
||||||
)
|
|
||||||
return nxt if nxt < len(parts) else None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _record_command_string(
|
@dataclass(frozen=True)
|
||||||
name: str,
|
class _Step:
|
||||||
option: str,
|
"""One wrapper resolved: its command, the hop it reaches, or its parse error."""
|
||||||
text: str,
|
|
||||||
command_strings: list[CommandString],
|
index: int | None = None
|
||||||
) -> None:
|
unparsed: str = ""
|
||||||
"""Note *text* when *option* hands a command line to wrapper *name*."""
|
remote: RemoteTarget | None = None
|
||||||
if option in _COMMAND_STRING_OPTS.get(name, frozenset()):
|
|
||||||
command_strings.append(CommandString(wrapper=name, option=option, text=text))
|
|
||||||
|
|
||||||
|
|
||||||
def _skip_options(
|
def _step(name: str, parts: list[str], start: int, *, command_strings: list[CommandString]) -> _Step:
|
||||||
parts: list[str],
|
"""Resolve the wrapper *name* at ``parts[start - 1]`` past its own options."""
|
||||||
start: int,
|
spec = _spec_for(name)
|
||||||
*,
|
if spec is None:
|
||||||
name: str,
|
return _Step()
|
||||||
value_opts: frozenset[str],
|
|
||||||
leading_duration: bool,
|
|
||||||
command_strings: list[CommandString],
|
|
||||||
) -> int:
|
|
||||||
"""Index of the first token after a wrapper's own options and assignments."""
|
|
||||||
index = start
|
index = start
|
||||||
took_duration = False
|
if spec.launcher:
|
||||||
while index < len(parts):
|
if index >= len(parts) or parts[index] != "run":
|
||||||
token = parts[index]
|
return _Step() # ``npm install``: the launcher is the whole command
|
||||||
if token == "--":
|
index += 1
|
||||||
return index + 1
|
parsed = _parse_options(parts, index, spec)
|
||||||
if _ASSIGNMENT.match(token):
|
if parsed.unparsed:
|
||||||
index += 1
|
return _Step(unparsed=parsed.unparsed)
|
||||||
continue
|
for option, value in parsed.options:
|
||||||
if token.startswith("-"):
|
if option in spec.command_opts:
|
||||||
index += 1
|
command_strings.append(CommandString(wrapper=name, option=option, text=value))
|
||||||
if token in value_opts:
|
index = _skip_prefixes(parts, parsed.index, spec)
|
||||||
value = parts[index] if index < len(parts) else ""
|
if spec.remote:
|
||||||
_record_command_string(name, token, value, command_strings)
|
return _remote_step(name, parts, index, parsed.options)
|
||||||
index += 1
|
return _Step(index=index) if index < len(parts) else _Step()
|
||||||
continue
|
|
||||||
# Jammed forms: ``--split-string=…`` and ``-S…`` carry the value in
|
|
||||||
# the token itself, so a command line may hide there too.
|
def _spec_for(name: str) -> _Spec | None:
|
||||||
option, equal, attached = token.partition("=")
|
"""The option spec of a wrapper, or ``None`` when *name* wraps nothing."""
|
||||||
if equal:
|
spec = _WRAPPERS.get(name) or _REMOTE_WRAPPERS.get(name)
|
||||||
_record_command_string(name, option, attached, command_strings)
|
if spec is None and name in _RUN_LAUNCHERS:
|
||||||
elif token[:2] in _COMMAND_STRING_OPTS.get(name, frozenset()):
|
return _LAUNCHER_SPEC
|
||||||
_record_command_string(name, token[:2], token[2:], command_strings)
|
return spec
|
||||||
continue
|
|
||||||
if leading_duration and not took_duration and _DURATION.fullmatch(token):
|
|
||||||
took_duration = True
|
def _skip_prefixes(parts: list[str], index: int, spec: _Spec) -> int:
|
||||||
index += 1
|
"""Skip the ``FOO=bar`` assignments and the bare duration a wrapper takes first."""
|
||||||
continue
|
while index < len(parts) and _ASSIGNMENT.match(parts[index]):
|
||||||
return index
|
index += 1
|
||||||
|
if spec.duration and index < len(parts) and _DURATION.fullmatch(parts[index]):
|
||||||
|
index += 1
|
||||||
return index
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_options(parts: list[str], start: int, spec: _Spec) -> _Parsed:
|
||||||
|
"""Read a wrapper's own options, stopping at the first token that is not one.
|
||||||
|
|
||||||
|
``getopt`` (POSIX order — not ``gnu_getopt``, which permutes) consumes the
|
||||||
|
wrapper's options and leaves the rest, so the length of that remainder is
|
||||||
|
where the wrapped program starts. Jammed forms (``-p2222``,
|
||||||
|
``-oBatchMode=yes``, ``--with=pkg``) are its business, not ours. An option
|
||||||
|
the spec does not know is *not* skipped as a flag — that is how a flag ended
|
||||||
|
up eating the program — but reported, so the review can ask.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
options, rest = getopt.getopt(list(parts[start:]), spec.short, list(spec.long))
|
||||||
|
except getopt.GetoptError as exc:
|
||||||
|
return _Parsed(index=start, unparsed=f"(cannot read its options: {exc})")
|
||||||
|
return _Parsed(options=tuple(options), index=len(parts) - len(rest))
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_step(name: str, parts: list[str], index: int, options: Sequence[tuple[str, str]]) -> _Step:
|
||||||
|
"""The hop a remote client reaches, and where its command starts (if any).
|
||||||
|
|
||||||
|
The destination is the first token that is not one of the client's options;
|
||||||
|
the command follows it, or — for ``mosh`` — follows an explicit ``--``. No
|
||||||
|
token after the destination means the client just opens a remote shell.
|
||||||
|
"""
|
||||||
|
if index >= len(parts):
|
||||||
|
return _Step()
|
||||||
|
host = parts[index]
|
||||||
|
index += 1
|
||||||
|
if name == "mosh" and index < len(parts) and parts[index] == "--":
|
||||||
|
index += 1
|
||||||
|
batch = _batch_mode(options)
|
||||||
|
if index >= len(parts):
|
||||||
|
return _Step(remote=RemoteTarget(wrapper=name, host=host, batch=batch, shell=True))
|
||||||
|
return _Step(index=index, remote=RemoteTarget(wrapper=name, host=host, batch=batch))
|
||||||
|
|
||||||
|
|
||||||
|
def _batch_mode(options: Sequence[tuple[str, str]]) -> bool:
|
||||||
|
"""Whether ``-o BatchMode=yes`` was given: the client never prompts."""
|
||||||
|
for option, value in options:
|
||||||
|
key, _, setting = value.partition("=")
|
||||||
|
if option == "-o" and key.strip().lower() == "batchmode":
|
||||||
|
return setting.strip().lower() in {"1", "on", "true", "yes"}
|
||||||
|
return False
|
||||||
|
|||||||
@@ -59,12 +59,26 @@ def _command_reason(argv: Sequence[str], *, via: str) -> DangerReason | None:
|
|||||||
program or a wrapper) is confirmed with ``yolo_skip=False``. It never adds a
|
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
|
reason of its own: the denylist gate in the handler always asks about the
|
||||||
escalation, so a second prompt here would only repeat it.
|
escalation, so a second prompt here would only repeat it.
|
||||||
|
|
||||||
|
A command on another machine (``ssh``/``mosh``) is reviewed by the program it
|
||||||
|
runs there — the client's destination and options are skipped. With
|
||||||
|
``-o BatchMode=yes`` the client cannot prompt at either end, so the command
|
||||||
|
is always confirmed, whatever program it is. A client with no command opens a
|
||||||
|
remote shell, which is announced: nothing typed or piped into it can be
|
||||||
|
checked, so the human decides before it starts.
|
||||||
"""
|
"""
|
||||||
scan = unwrap_command(argv)
|
scan = unwrap_command(argv)
|
||||||
interpreter = is_interpreter(scan.base)
|
interpreter = is_interpreter(scan.base)
|
||||||
findings = [f"{name} (detached run)" for name in scan.self_review]
|
findings = [f"{name} (detached run)" for name in scan.self_review]
|
||||||
if interpreter:
|
if interpreter:
|
||||||
findings.append(f"interpreter {scan.base!r}")
|
findings.append(f"interpreter {scan.base!r}")
|
||||||
|
remote = scan.remote
|
||||||
|
if remote is not None and remote.shell:
|
||||||
|
findings.append(f"remote shell on {remote.host!r} ({remote.wrapper} with no command; its input is not checked)")
|
||||||
|
elif remote is not None and remote.batch:
|
||||||
|
findings.append(f"remote command on {remote.host!r} (BatchMode=yes, no prompt on either end)")
|
||||||
|
if scan.unparsed:
|
||||||
|
findings.append(f"options not understood {scan.unparsed} — the wrapped program is a guess")
|
||||||
findings.extend(f"{span.label} runs a command line" for span in scan.command_strings)
|
findings.extend(f"{span.label} runs a command line" for span in scan.command_strings)
|
||||||
findings = list(dict.fromkeys(findings))
|
findings = list(dict.fromkeys(findings))
|
||||||
if not findings:
|
if not findings:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, cast
|
|||||||
|
|
||||||
from plyngent.agent import ToolOutput, ToolTag, tool
|
from plyngent.agent import ToolOutput, ToolTag, tool
|
||||||
from plyngent.agent.images import image_part_from_bytes
|
from plyngent.agent.images import image_part_from_bytes
|
||||||
from plyngent.tools.workspace import AccessMode, resolve_path
|
from plyngent.tools.workspace import AccessMode, image_input_refusal, resolve_path
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from plyngent.lmproto.openai_compatible.model import ImageDetail
|
from plyngent.lmproto.openai_compatible.model import ImageDetail
|
||||||
@@ -27,7 +27,9 @@ async def view_image(path: str, detail: str = "auto") -> str | ToolOutput:
|
|||||||
picture as it is, and ``auto`` (the default) lets the provider decide.
|
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
|
The format is read from the file's own bytes, so a misnamed file still
|
||||||
works; anything else comes back as an error.
|
works; anything else comes back as an error. A model whose
|
||||||
|
``[providers.*.models.*].image_in`` is off gets an error naming that key
|
||||||
|
instead of an attachment it could never read.
|
||||||
"""
|
"""
|
||||||
if detail not in _DETAILS:
|
if detail not in _DETAILS:
|
||||||
return f"error: detail must be one of {', '.join(sorted(_DETAILS))}"
|
return f"error: detail must be one of {', '.join(sorted(_DETAILS))}"
|
||||||
@@ -36,6 +38,9 @@ async def view_image(path: str, detail: str = "auto") -> str | ToolOutput:
|
|||||||
return f"error: file not found: {path}"
|
return f"error: file not found: {path}"
|
||||||
if not target.is_file():
|
if not target.is_file():
|
||||||
return f"error: not a file: {path}"
|
return f"error: not a file: {path}"
|
||||||
|
refusal = image_input_refusal()
|
||||||
|
if refusal is not None:
|
||||||
|
return refusal
|
||||||
part = image_part_from_bytes(target.read_bytes(), detail=cast("ImageDetail", detail))
|
part = image_part_from_bytes(target.read_bytes(), detail=cast("ImageDetail", detail))
|
||||||
if isinstance(part, str):
|
if isinstance(part, str):
|
||||||
return f"error: {path}: {part.removeprefix('error: ')}"
|
return f"error: {path}: {part.removeprefix('error: ')}"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from plyngent.tools.net.policy import (
|
|||||||
soft_confirm_reason,
|
soft_confirm_reason,
|
||||||
)
|
)
|
||||||
from plyngent.tools.truncate_token import truncate_to_memory
|
from plyngent.tools.truncate_token import truncate_to_memory
|
||||||
|
from plyngent.tools.workspace import image_input_refusal
|
||||||
|
|
||||||
|
|
||||||
def format_fetch_result(
|
def format_fetch_result(
|
||||||
@@ -147,10 +148,14 @@ def _attach_image(text: str, result: FetchResult) -> str | ToolOutput:
|
|||||||
"""An image response is handed over as a picture, beside the usual metadata.
|
"""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
|
The preview line stays in the text so ``body_kind=image`` and the byte count
|
||||||
are still visible to the model.
|
are still visible to the model. A model without ``image_in`` keeps the
|
||||||
|
metadata and is told why the picture stayed behind.
|
||||||
"""
|
"""
|
||||||
if result.body_kind != "image" or not result.body_image:
|
if result.body_kind != "image" or not result.body_image:
|
||||||
return text
|
return text
|
||||||
|
refusal = image_input_refusal()
|
||||||
|
if refusal is not None:
|
||||||
|
return f"{text}\n{refusal}"
|
||||||
part = image_part_from_bytes(result.body_image)
|
part = image_part_from_bytes(result.body_image)
|
||||||
if isinstance(part, str):
|
if isinstance(part, str):
|
||||||
return f"{text}\n{part}"
|
return f"{text}\n{part}"
|
||||||
|
|||||||
@@ -10,11 +10,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
# Match common \xNN / \uNNNN / named ctrl+ / esc sequences in write_pty_keys data.
|
# Escapes and ``<name>`` key markers in write_pty_keys data.
|
||||||
_HEX_BYTE = re.compile(r"\\x([0-9A-Fa-f]{2})")
|
_HEX_BYTE = re.compile(r"\\x([0-9A-Fa-f]{2})")
|
||||||
_HEX_U = re.compile(r"\\u([0-9A-Fa-f]{4})")
|
_HEX_U = re.compile(r"\\u([0-9A-Fa-f]{4})")
|
||||||
_CTRL = re.compile(r"ctrl\+([a-z@\[\\\]\^_])", re.IGNORECASE)
|
|
||||||
_KEY = re.compile(r"key=(esc|escape|enter|tab|up|down|left|right)", re.IGNORECASE)
|
|
||||||
_SIMPLE = {
|
_SIMPLE = {
|
||||||
r"\n": "\n",
|
r"\n": "\n",
|
||||||
r"\r": "\r",
|
r"\r": "\r",
|
||||||
@@ -23,18 +21,84 @@ _SIMPLE = {
|
|||||||
r"\E": "\x1b",
|
r"\E": "\x1b",
|
||||||
r"\0": "\x00",
|
r"\0": "\x00",
|
||||||
}
|
}
|
||||||
|
_SIMPLE_ESCAPE_LEN = 2
|
||||||
|
|
||||||
_KEY_MAP = {
|
#: ``<name>`` markers: the keys a terminal understands, and the bytes each sends.
|
||||||
|
_KEY_NAMES: dict[str, str] = {
|
||||||
"esc": "\x1b",
|
"esc": "\x1b",
|
||||||
"escape": "\x1b",
|
|
||||||
"enter": "\r",
|
"enter": "\r",
|
||||||
"tab": "\t",
|
"tab": "\t",
|
||||||
|
"s-tab": "\x1b[Z",
|
||||||
|
"space": " ",
|
||||||
|
"bs": "\x7f",
|
||||||
|
"del": "\x1b[3~",
|
||||||
|
"ins": "\x1b[2~",
|
||||||
|
"home": "\x1b[H",
|
||||||
|
"end": "\x1b[F",
|
||||||
"up": "\x1b[A",
|
"up": "\x1b[A",
|
||||||
"down": "\x1b[B",
|
"down": "\x1b[B",
|
||||||
"right": "\x1b[C",
|
|
||||||
"left": "\x1b[D",
|
"left": "\x1b[D",
|
||||||
|
"right": "\x1b[C",
|
||||||
|
"pgup": "\x1b[5~",
|
||||||
|
"pgdn": "\x1b[6~",
|
||||||
|
"f1": "\x1bOP",
|
||||||
|
"f2": "\x1bOQ",
|
||||||
|
"f3": "\x1bOR",
|
||||||
|
"f4": "\x1bOS",
|
||||||
|
"f5": "\x1b[15~",
|
||||||
|
"f6": "\x1b[17~",
|
||||||
|
"f7": "\x1b[18~",
|
||||||
|
"f8": "\x1b[19~",
|
||||||
|
"f9": "\x1b[20~",
|
||||||
|
"f10": "\x1b[21~",
|
||||||
|
"f11": "\x1b[23~",
|
||||||
|
"f12": "\x1b[24~",
|
||||||
}
|
}
|
||||||
_SIMPLE_ESCAPE_LEN = 2
|
|
||||||
|
#: The longer spellings, so a marker can be written the way it is usually named.
|
||||||
|
_KEY_ALIASES: dict[str, str] = {
|
||||||
|
"escape": "esc",
|
||||||
|
"return": "enter",
|
||||||
|
"backspace": "bs",
|
||||||
|
"delete": "del",
|
||||||
|
"insert": "ins",
|
||||||
|
"pageup": "pgup",
|
||||||
|
"pagedown": "pgdn",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Keys a modifier applies to, as the xterm sequence with the modifier slot.
|
||||||
|
_MODIFIED: dict[str, str] = {
|
||||||
|
"up": "\x1b[1;{m}A",
|
||||||
|
"down": "\x1b[1;{m}B",
|
||||||
|
"right": "\x1b[1;{m}C",
|
||||||
|
"left": "\x1b[1;{m}D",
|
||||||
|
"home": "\x1b[1;{m}H",
|
||||||
|
"end": "\x1b[1;{m}F",
|
||||||
|
"ins": "\x1b[2;{m}~",
|
||||||
|
"del": "\x1b[3;{m}~",
|
||||||
|
"pgup": "\x1b[5;{m}~",
|
||||||
|
"pgdn": "\x1b[6;{m}~",
|
||||||
|
"f1": "\x1b[1;{m}P",
|
||||||
|
"f2": "\x1b[1;{m}Q",
|
||||||
|
"f3": "\x1b[1;{m}R",
|
||||||
|
"f4": "\x1b[1;{m}S",
|
||||||
|
"f5": "\x1b[15;{m}~",
|
||||||
|
"f6": "\x1b[17;{m}~",
|
||||||
|
"f7": "\x1b[18;{m}~",
|
||||||
|
"f8": "\x1b[19;{m}~",
|
||||||
|
"f9": "\x1b[20;{m}~",
|
||||||
|
"f10": "\x1b[21;{m}~",
|
||||||
|
"f11": "\x1b[23;{m}~",
|
||||||
|
"f12": "\x1b[24;{m}~",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Modifier prefixes a marker may carry: Ctrl, Alt (either spelling), Shift.
|
||||||
|
_MODIFIERS = frozenset({"c", "a", "m", "s"})
|
||||||
|
|
||||||
|
#: What a Ctrl may be combined with beyond A-Z: the rest of the C0 controls.
|
||||||
|
_CONTROL_CHARS = "@[\\]^_"
|
||||||
|
#: Longest marker content to look for, so a stray ``<`` stays a literal ``<``.
|
||||||
|
_MAX_MARKER = 32
|
||||||
|
|
||||||
# C0 controls except TAB/LF/CR (those stay for readable logs).
|
# C0 controls except TAB/LF/CR (those stay for readable logs).
|
||||||
_UNSAFE_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
_UNSAFE_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
||||||
@@ -58,15 +122,110 @@ def sanitize_pty_output_for_tool(data: str) -> str:
|
|||||||
return _UNSAFE_CTRL.sub(_esc_ctrl, out)
|
return _UNSAFE_CTRL.sub(_esc_ctrl, out)
|
||||||
|
|
||||||
|
|
||||||
|
class KeySyntaxError(ValueError):
|
||||||
|
"""A ``<...>`` marker that names no key, or repeats it wrongly."""
|
||||||
|
|
||||||
|
|
||||||
|
def _control(char: str) -> str:
|
||||||
|
"""The C0 byte for a Ctrl and one character."""
|
||||||
|
if len(char) != 1:
|
||||||
|
msg = f"ctrl takes one character, not {char!r} (write <c-a>)"
|
||||||
|
raise KeySyntaxError(msg)
|
||||||
|
upper = char.upper()
|
||||||
|
if not ("A" <= upper <= "Z" or char in _CONTROL_CHARS):
|
||||||
|
msg = f"no control byte for <c-{char}>"
|
||||||
|
raise KeySyntaxError(msg)
|
||||||
|
return chr(ord(upper) & 0x1F)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(name: str) -> str:
|
||||||
|
lowered = name.lower()
|
||||||
|
return _KEY_ALIASES.get(lowered, lowered)
|
||||||
|
|
||||||
|
|
||||||
|
def _key_bytes(name: str) -> str:
|
||||||
|
"""What one ``<name>`` marker sends, modifiers and all."""
|
||||||
|
known = _KEY_NAMES.get(_canonical(name))
|
||||||
|
if known is not None:
|
||||||
|
return known
|
||||||
|
*prefixes, base = name.split("-")
|
||||||
|
modifiers = {prefix.lower() for prefix in prefixes}
|
||||||
|
if prefixes and base and modifiers <= _MODIFIERS:
|
||||||
|
return _modified(name, base, modifiers)
|
||||||
|
msg = f"unknown key <{name}> (see the write_pty_keys docs for the names)"
|
||||||
|
raise KeySyntaxError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _modified(name: str, base: str, modifiers: set[str]) -> str:
|
||||||
|
"""One key under Ctrl / Alt / Shift, spelled the way xterm spells it."""
|
||||||
|
ctrl = "c" in modifiers
|
||||||
|
alt = bool(modifiers & {"a", "m"})
|
||||||
|
shift = "s" in modifiers
|
||||||
|
template = _MODIFIED.get(_canonical(base))
|
||||||
|
if template is not None:
|
||||||
|
# xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4).
|
||||||
|
return template.format(m=1 + shift * 1 + alt * 2 + ctrl * 4)
|
||||||
|
if ctrl and _canonical(base) == "space":
|
||||||
|
return "\x00" # Ctrl+Space is NUL, not a CSI sequence
|
||||||
|
if len(base) == 1:
|
||||||
|
if shift:
|
||||||
|
msg = f"<s-{base}> is just {base.upper()!r} — drop the modifier"
|
||||||
|
raise KeySyntaxError(msg)
|
||||||
|
return ("\x1b" if alt else "") + (_control(base) if ctrl else base)
|
||||||
|
known = _KEY_NAMES.get(_canonical(base))
|
||||||
|
if known is not None and not (ctrl or shift):
|
||||||
|
return "\x1b" + known # Alt alone: an ESC before the key, as terminals send it
|
||||||
|
msg = f"no sequence for <{name}> (write the raw bytes with \\x1b)"
|
||||||
|
raise KeySyntaxError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _expansion(content: str) -> str:
|
||||||
|
"""The bytes for the inside of one marker, honouring a ``*N`` repeat."""
|
||||||
|
name, _, count = content.partition("*")
|
||||||
|
if not count:
|
||||||
|
return _key_bytes(name)
|
||||||
|
if not count.isdigit() or int(count) < 1:
|
||||||
|
msg = f"bad repeat in <{content}> (write <up*3>)"
|
||||||
|
raise KeySyntaxError(msg)
|
||||||
|
return _key_bytes(name) * int(count)
|
||||||
|
|
||||||
|
|
||||||
|
def _marker_at(data: str, index: int) -> tuple[str, int] | None:
|
||||||
|
"""The marker starting at *index*, as ``(content, next index)``, or ``None``.
|
||||||
|
|
||||||
|
A ``<`` that is not followed by a short, whitespace-free ``>`` pair stays a
|
||||||
|
literal ``<`` — ordinary input like ``sort < file`` must not need escaping.
|
||||||
|
"""
|
||||||
|
end = data.find(">", index + 1, index + 1 + _MAX_MARKER)
|
||||||
|
if end <= index + 1:
|
||||||
|
return None
|
||||||
|
content = data[index + 1 : end]
|
||||||
|
if any(char.isspace() or char == "<" for char in content):
|
||||||
|
return None
|
||||||
|
return content, end + 1
|
||||||
|
|
||||||
|
|
||||||
def decode_write_data(data: str) -> str:
|
def decode_write_data(data: str) -> str:
|
||||||
"""Expand agent-friendly escapes into raw PTY input (for ``write_pty_keys`` only).
|
"""Expand ``<key>`` markers and escapes into raw PTY input (``write_pty_keys``).
|
||||||
|
|
||||||
Supported (as literal characters in ``data``):
|
Markers, written between angle brackets:
|
||||||
|
|
||||||
- ``\\n`` ``\\r`` ``\\t`` ``\\e`` / ``\\E`` (ESC) ``\\0``
|
- ``<esc>`` ``<enter>`` ``<tab>`` ``<s-tab>`` ``<space>`` ``<bs>`` ``<del>``
|
||||||
- ``\\xHH`` byte, ``\\uHHHH`` Unicode code point
|
``<ins>`` ``<home>`` ``<end>`` ``<up>`` ``<down>`` ``<left>`` ``<right>``
|
||||||
- ``ctrl+c`` / ``ctrl+x`` ... (case-insensitive; A-Z or @ [ \\ ] ^ _)
|
``<pgup>`` ``<pgdn>`` ``<f1>`` … ``<f12>``
|
||||||
- ``key=esc`` ``key=enter`` ``key=tab`` ``key=up|down|left|right``
|
- a modifier in front, alone or combined: ``<c-up>`` ``<s-home>``
|
||||||
|
``<m-tab>`` ``<c-s-f5>`` — the xterm sequence where a key has one. On a
|
||||||
|
plain character, Ctrl is its control byte (``<c-a>``, ``<c-[>``) and Alt
|
||||||
|
is an ESC before it (``<m-x>``)
|
||||||
|
- ``<up*3>`` repeats a key three times, ``<<`` is a literal ``<``
|
||||||
|
|
||||||
|
An unknown name raises :class:`KeySyntaxError` rather than typing the marker
|
||||||
|
into the terminal. Anything that is not a marker or an escape is literal.
|
||||||
|
|
||||||
|
Escapes (literal characters in ``data``):
|
||||||
|
|
||||||
|
- ``\\\\n`` ``\\\\r`` ``\\\\t`` ``\\\\e`` / ``\\\\E`` (ESC) ``\\\\0``
|
||||||
|
- ``\\\\xHH`` byte, ``\\\\uHHHH`` Unicode code point
|
||||||
"""
|
"""
|
||||||
if not data:
|
if not data:
|
||||||
return data
|
return data
|
||||||
@@ -75,18 +234,20 @@ def decode_write_data(data: str) -> str:
|
|||||||
i = 0
|
i = 0
|
||||||
n = len(data)
|
n = len(data)
|
||||||
while i < n:
|
while i < n:
|
||||||
|
if data[i] == "<":
|
||||||
|
if data.startswith("<<", i):
|
||||||
|
out.append("<") # the escape for a literal one
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
marker = _marker_at(data, i)
|
||||||
|
if marker is not None:
|
||||||
|
content, i = marker
|
||||||
|
out.append(_expansion(content))
|
||||||
|
continue
|
||||||
|
out.append("<") # a `<` that opens nothing
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
rest = data[i:]
|
rest = data[i:]
|
||||||
m_key = _KEY.match(rest)
|
|
||||||
if m_key is not None:
|
|
||||||
out.append(_KEY_MAP[m_key.group(1).lower()])
|
|
||||||
i += m_key.end()
|
|
||||||
continue
|
|
||||||
m_ctrl = _CTRL.match(rest)
|
|
||||||
if m_ctrl is not None:
|
|
||||||
ch = m_ctrl.group(1).upper()
|
|
||||||
out.append(chr((ord(ch) - ord("@")) & 0x1F))
|
|
||||||
i += m_ctrl.end()
|
|
||||||
continue
|
|
||||||
m_hex = _HEX_BYTE.match(rest)
|
m_hex = _HEX_BYTE.match(rest)
|
||||||
if m_hex is not None:
|
if m_hex is not None:
|
||||||
out.append(chr(int(m_hex.group(1), 16)))
|
out.append(chr(int(m_hex.group(1), 16)))
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ def write_pty_payload(session_id: int, raw: str) -> str:
|
|||||||
async def write_pty(session_id: int, data: str) -> str:
|
async def write_pty(session_id: int, data: str) -> str:
|
||||||
"""Write **literal** text to a PTY session. Does not append a newline.
|
"""Write **literal** text to a PTY session. Does not append a newline.
|
||||||
|
|
||||||
``data`` is sent unchanged (no ``ctrl+x`` / ``\\\\xHH`` expansion). For
|
``data`` is sent unchanged (no ``<enter>`` / ``\\xHH`` expansion). For
|
||||||
control sequences use :func:`write_pty_keys`.
|
control sequences use :func:`write_pty_keys`.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -3,27 +3,39 @@ from __future__ import annotations
|
|||||||
from plyngent.agent import ToolTag, tool
|
from plyngent.agent import ToolTag, tool
|
||||||
from plyngent.tools.workspace import WorkspaceError
|
from plyngent.tools.workspace import WorkspaceError
|
||||||
|
|
||||||
from .pty_terminal import decode_write_data
|
from .pty_terminal import KeySyntaxError, decode_write_data
|
||||||
from .write_pty import write_pty_payload
|
from .write_pty import write_pty_payload
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
||||||
async def write_pty_keys(session_id: int, data: str) -> str:
|
async def write_pty_keys(session_id: int, data: str) -> str:
|
||||||
"""Write to a PTY after expanding key escapes (never for normal typing).
|
"""Write to a PTY after expanding ``<key>`` markers (never for normal typing).
|
||||||
|
|
||||||
Use this only for control sequences. Prefer :func:`write_pty` for plain text
|
Use this only for control sequences. Prefer :func:`write_pty` for plain text
|
||||||
so strings like ``press ctrl+c`` are not rewritten.
|
so strings like ``press <c-c>`` are not rewritten.
|
||||||
|
|
||||||
Escapes (literal characters in ``data``):
|
Markers, written between angle brackets:
|
||||||
|
|
||||||
- ``\\\\n`` ``\\\\r`` ``\\\\t`` ``\\\\e``/``\\\\E`` (ESC) ``\\\\0``
|
- ``<esc>`` ``<enter>`` ``<tab>`` ``<s-tab>`` ``<space>`` ``<bs>`` ``<del>``
|
||||||
- ``\\\\xHH`` byte, ``\\\\uHHHH`` Unicode code point
|
``<ins>`` ``<home>`` ``<end>`` ``<up>`` ``<down>`` ``<left>`` ``<right>``
|
||||||
- ``ctrl+c`` / ``ctrl+x`` ... (case-insensitive)
|
``<pgup>`` ``<pgdn>`` ``<f1>`` … ``<f12>``
|
||||||
- ``key=esc|enter|tab|up|down|left|right``
|
- a modifier in front, alone or combined: ``<c-up>`` ``<s-home>``
|
||||||
|
``<m-tab>`` ``<c-s-f5>``; on a plain character Ctrl is its control byte
|
||||||
|
(``<c-a>``) and Alt an ESC before it (``<m-x>``)
|
||||||
|
- ``<up*3>`` repeats a key, ``<<`` is a literal ``<``
|
||||||
|
|
||||||
|
An unknown marker is an error, not text typed into the terminal: a typo must
|
||||||
|
not land in a shell prompt.
|
||||||
|
|
||||||
|
Escapes (as the characters ``data`` carries, so doubled inside JSON):
|
||||||
|
``\\\\n`` ``\\\\r`` ``\\\\t`` ``\\\\e``/``\\\\E`` (ESC) ``\\\\0``,
|
||||||
|
``\\\\xHH`` byte, ``\\\\uHHHH`` code point. Anything else is literal.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
raw = decode_write_data(data)
|
raw = decode_write_data(data)
|
||||||
return write_pty_payload(session_id, raw)
|
return write_pty_payload(session_id, raw)
|
||||||
|
except KeySyntaxError as exc:
|
||||||
|
return f"error: {exc}"
|
||||||
except WorkspaceError as exc:
|
except WorkspaceError as exc:
|
||||||
return f"error: {exc}"
|
return f"error: {exc}"
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
|
|||||||
@@ -122,6 +122,14 @@ class WorkspacePolicy:
|
|||||||
static_read: list[Path] = field(default_factory=list)
|
static_read: list[Path] = field(default_factory=list)
|
||||||
# ``[trash] enabled``: deletes go to the system trash unless cleared.
|
# ``[trash] enabled``: deletes go to the system trash unless cleared.
|
||||||
trash_enabled: bool = True
|
trash_enabled: bool = True
|
||||||
|
# ``[providers.*.models.*].image_in`` of the model this host is talking to:
|
||||||
|
# ``None`` = the host did not say, so a picture is passed through unchanged.
|
||||||
|
# ``False`` makes every image-carrying tool refuse, instead of sending bytes
|
||||||
|
# the model cannot read (the provider answers ``[Unsupported Image]``, or a
|
||||||
|
# gateway that ignores the block hands the model raw base64).
|
||||||
|
image_in: bool | None = None
|
||||||
|
# ``(provider name, model id)`` that verdict came from, for the message.
|
||||||
|
image_model: tuple[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
def _bound_instance() -> InstanceState | None:
|
def _bound_instance() -> InstanceState | None:
|
||||||
@@ -200,6 +208,40 @@ def get_trash_enabled() -> bool:
|
|||||||
return active_workspace_policy().trash_enabled
|
return active_workspace_policy().trash_enabled
|
||||||
|
|
||||||
|
|
||||||
|
def set_image_in(*, image_in: bool | None, provider: str | None = None, model: str | None = None) -> None:
|
||||||
|
"""Record whether the current model takes pictures (``image_in``).
|
||||||
|
|
||||||
|
``None`` means the host did not declare it, and an image is sent as usual;
|
||||||
|
``False`` is the explicit opt-out the image-carrying tools and the agent's
|
||||||
|
tool registry check through :func:`image_input_refusal`.
|
||||||
|
"""
|
||||||
|
policy = active_workspace_policy()
|
||||||
|
policy.image_in = image_in
|
||||||
|
policy.image_model = (provider, model) if provider and model else None
|
||||||
|
|
||||||
|
|
||||||
|
def image_input_refusal() -> str | None:
|
||||||
|
"""``error: …`` text for an image the current model cannot take, else ``None``.
|
||||||
|
|
||||||
|
Mirrors the ``@path`` mention gate (``cli/mentions.py``): a picture is not
|
||||||
|
sent to a model whose ``image_in`` is off. The message names the config key
|
||||||
|
that turns it on, so the fix is one line away. With no instance bound the
|
||||||
|
host has declared nothing, and the picture passes through.
|
||||||
|
"""
|
||||||
|
instance = _bound_instance()
|
||||||
|
if instance is None:
|
||||||
|
return None
|
||||||
|
policy = instance.workspace
|
||||||
|
if policy.image_in is not False:
|
||||||
|
return None
|
||||||
|
named = policy.image_model
|
||||||
|
if named is None:
|
||||||
|
return "error: this model does not accept image input (set image_in = true in the provider config)"
|
||||||
|
provider, model = named
|
||||||
|
hint = f'set image_in = true under [providers.{provider}.models."{model}"]'
|
||||||
|
return f"error: model {model!r} does not accept image input ({hint})"
|
||||||
|
|
||||||
|
|
||||||
def set_command_denylist(names: list[str] | tuple[str, ...] | frozenset[str] | None) -> None:
|
def set_command_denylist(names: list[str] | tuple[str, ...] | frozenset[str] | None) -> None:
|
||||||
"""Set denied command basenames (None restores defaults)."""
|
"""Set denied command basenames (None restores defaults)."""
|
||||||
policy = active_workspace_policy()
|
policy = active_workspace_policy()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from plyngent.lmproto.openai_compatible.model import (
|
|||||||
ToolChatMessage,
|
ToolChatMessage,
|
||||||
UserChatMessage,
|
UserChatMessage,
|
||||||
)
|
)
|
||||||
|
from plyngent.tools.context import InstanceState
|
||||||
|
|
||||||
|
|
||||||
def _image() -> ChatImagePart:
|
def _image() -> ChatImagePart:
|
||||||
@@ -100,3 +101,36 @@ def test_the_char_estimate_counts_images() -> None:
|
|||||||
assert estimate_message_chars(ToolChatMessage(content="shot", tool_call_id="call-1", images=[_image()])) == (
|
assert estimate_message_chars(ToolChatMessage(content="shot", tool_call_id="call-1", images=[_image()])) == (
|
||||||
len("shot") + len("call-1") + IMAGE_CHARS
|
len("shot") + len("call-1") + IMAGE_CHARS
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_for(instance: InstanceState) -> ToolRegistry:
|
||||||
|
@tool(register=False)
|
||||||
|
def shot() -> ToolOutput:
|
||||||
|
"""Pretend to take a screenshot."""
|
||||||
|
return ToolOutput(text="image: shot.png (2 KB)", images=[_image()])
|
||||||
|
|
||||||
|
return ToolRegistry([shot], instance_state=instance)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_text_only_model_never_receives_a_plugin_tool_image() -> None:
|
||||||
|
"""The registry is the safety net for tools that do not check ``image_in``."""
|
||||||
|
instance = InstanceState()
|
||||||
|
instance.workspace.image_in = False
|
||||||
|
instance.workspace.image_model = ("ds", "text-only")
|
||||||
|
|
||||||
|
output = await _registry_for(instance).execute_output("shot", "{}")
|
||||||
|
|
||||||
|
assert output.text.startswith("image: shot.png (2 KB)")
|
||||||
|
assert "image dropped" in output.text
|
||||||
|
assert "image_in = true" in output.text
|
||||||
|
assert not output.has_images
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_vision_model_keeps_a_plugin_tool_image() -> None:
|
||||||
|
instance = InstanceState()
|
||||||
|
instance.workspace.image_in = True
|
||||||
|
|
||||||
|
output = await _registry_for(instance).execute_output("shot", "{}")
|
||||||
|
|
||||||
|
assert output.text == "image: shot.png (2 KB)"
|
||||||
|
assert output.images == [_image()]
|
||||||
|
|||||||
@@ -801,18 +801,33 @@ async def test_pretty_pty_write_and_keys(capsys: pytest.CaptureFixture[str]) ->
|
|||||||
status = "session_id=3\nalive=true\nexit_code=\nwrote=5"
|
status = "session_id=3\nalive=true\nexit_code=\nwrote=5"
|
||||||
await render_events(_aiter([_pretty_call("write_pty", '{"session_id": 3, "data": "ls -la"}'), _result(status)]))
|
await render_events(_aiter([_pretty_call("write_pty", '{"session_id": 3, "data": "ls -la"}'), _result(status)]))
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "* PTY Write 3 (5 chars)" in out
|
assert "* PTY Write 3 'ls -la' (5 chars)" in out
|
||||||
|
|
||||||
await render_events(
|
await render_events(
|
||||||
_aiter(
|
_aiter(
|
||||||
[
|
[
|
||||||
_pretty_call("write_pty_keys", '{"session_id": 3, "data": "ctrl+c"}'),
|
_pretty_call("write_pty_keys", '{"session_id": 3, "data": "<c-c><enter>"}'),
|
||||||
_result("session_id=3\nalive=true\nexit_code=\nwrote=1"),
|
_result("session_id=3\nalive=true\nexit_code=\nwrote=2"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "* PTY Keys 3 (1 char)" in out
|
assert "* PTY Keys 3 '<c-c><enter>' (2 chars)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pretty_pty_write_escapes_and_shortens_the_payload(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""A newline or an ESC must not break the line, and a long payload is cut."""
|
||||||
|
status = "session_id=3\nalive=true\nexit_code=\nwrote=4"
|
||||||
|
await render_events(_aiter([_pretty_call("write_pty", '{"session_id": 3, "data": "a\\nb"}'), _result(status)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* PTY Write 3 'a\\nb' (4 chars)" in out
|
||||||
|
|
||||||
|
long = "x" * 60
|
||||||
|
call = _pretty_call("write_pty", f'{{"session_id": 3, "data": "{long}"}}')
|
||||||
|
await render_events(_aiter([call, _result(status)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "* PTY Write 3 '" + "x" * 40 + "…'" in out
|
||||||
|
assert "x" * 41 not in out
|
||||||
|
|
||||||
|
|
||||||
async def test_pretty_ask_into_pty_never_shows_the_answer(capsys: pytest.CaptureFixture[str]) -> None:
|
async def test_pretty_ask_into_pty_never_shows_the_answer(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import sys
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -253,6 +254,60 @@ def test_tool_confirm_denied_by_the_notification() -> None:
|
|||||||
assert prompt_confirm_tool("run_argv", {}, "runs a command") is False
|
assert prompt_confirm_tool("run_argv", {}, "runs a command") is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform == "win32", reason="a pty is POSIX-only")
|
||||||
|
def test_a_button_replaces_the_terminal_read(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""A button pressed in the notification answers the waiting prompt.
|
||||||
|
|
||||||
|
The read consults stdin *and* the notification while it blocks: the button
|
||||||
|
only exists once the notification is shown, which is after the read began.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from plyngent.cli.notify import ALLOW, DesktopRequest, WaitNotifier
|
||||||
|
from plyngent.config.models import NotifyConfig
|
||||||
|
from plyngent.prompting import set_prompt_wait_hook
|
||||||
|
|
||||||
|
class PressesTheButton:
|
||||||
|
"""A desktop surface that answers once the notification is up."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.requests: list[DesktopRequest] = []
|
||||||
|
|
||||||
|
def capabilities(self) -> frozenset[str]:
|
||||||
|
return frozenset({"buttons", "reply"})
|
||||||
|
|
||||||
|
def send(self, request: DesktopRequest) -> str | None:
|
||||||
|
self.requests.append(request)
|
||||||
|
answer = request.on_answer
|
||||||
|
if answer is not None:
|
||||||
|
threading.Timer(0.2, answer, args=(ALLOW,)).start()
|
||||||
|
return "id-1"
|
||||||
|
|
||||||
|
def clear(self, identifier: str | None) -> None:
|
||||||
|
del identifier
|
||||||
|
|
||||||
|
desktop = PressesTheButton()
|
||||||
|
notifier = WaitNotifier(
|
||||||
|
NotifyConfig(grace_seconds=0.0, min_interval_seconds=0.0),
|
||||||
|
desktop=desktop,
|
||||||
|
write=lambda _sequence: None,
|
||||||
|
)
|
||||||
|
master, slave = pty.openpty()
|
||||||
|
stream = os.fdopen(slave)
|
||||||
|
set_prompt_wait_hook(notifier)
|
||||||
|
monkeypatch.setattr(sys, "stdin", stream)
|
||||||
|
try:
|
||||||
|
with temporary_backend(ScriptedBackend([])):
|
||||||
|
assert prompt_confirm_tool("run_argv", {}, "runs a command") is True
|
||||||
|
finally:
|
||||||
|
set_prompt_wait_hook(None)
|
||||||
|
stream.close()
|
||||||
|
os.close(master)
|
||||||
|
assert desktop.requests
|
||||||
|
|
||||||
|
|
||||||
def test_batch_confirm_takes_a_notification_answer() -> None:
|
def test_batch_confirm_takes_a_notification_answer() -> None:
|
||||||
from plyngent.agent import ToolConfirmRequest
|
from plyngent.agent import ToolConfirmRequest
|
||||||
from plyngent.cli.limits import _prompt_confirm_batch_sync
|
from plyngent.cli.limits import _prompt_confirm_batch_sync
|
||||||
@@ -277,6 +332,80 @@ def test_batch_confirm_denied_by_the_notification() -> None:
|
|||||||
assert _prompt_confirm_batch_sync(requests).allowed is False
|
assert _prompt_confirm_batch_sync(requests).allowed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_confirm_can_ask_for_one_at_a_time_from_the_notification() -> None:
|
||||||
|
"""``m`` is offered as a button, so the manual review stays reachable."""
|
||||||
|
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("m"):
|
||||||
|
decision = _prompt_confirm_batch_sync(requests)
|
||||||
|
assert decision.allowed is False
|
||||||
|
assert decision.each is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_confirm_offers_its_three_answers() -> None:
|
||||||
|
from plyngent.agent import ToolConfirmRequest
|
||||||
|
from plyngent.cli.limits import _prompt_confirm_batch_sync
|
||||||
|
from plyngent.prompting import get_prompt_wait_hook, set_prompt_wait_hook
|
||||||
|
|
||||||
|
offered: list[tuple[tuple[str, str], ...]] = []
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def hook(wait: object) -> Generator[None]:
|
||||||
|
offered.append(wait.choices) # type: ignore[attr-defined]
|
||||||
|
yield None
|
||||||
|
|
||||||
|
requests = [ToolConfirmRequest(name="run_argv", args={}, reason="one")]
|
||||||
|
previous = get_prompt_wait_hook()
|
||||||
|
set_prompt_wait_hook(hook)
|
||||||
|
try:
|
||||||
|
with temporary_backend(ScriptedBackend(["n"])):
|
||||||
|
_ = _prompt_confirm_batch_sync(requests)
|
||||||
|
finally:
|
||||||
|
set_prompt_wait_hook(previous)
|
||||||
|
assert [key for key, _label in offered[0]] == ["y", "m", "n"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_limit_prompt_takes_its_answer_from_the_notification() -> None:
|
||||||
|
from plyngent.cli import limits
|
||||||
|
|
||||||
|
with temporary_backend(ScriptedBackend([])), _answered("y"):
|
||||||
|
assert limits.prompt_continue_limit("hit a wall") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_limit_prompt_can_stop_asking_from_the_notification(clean_auto_continue: None) -> None:
|
||||||
|
"""The ``yyy`` button is the one answer a single ``y`` cannot express."""
|
||||||
|
del clean_auto_continue
|
||||||
|
from plyngent.cli import limits
|
||||||
|
|
||||||
|
with temporary_backend(ScriptedBackend([])), _answered("yyy"):
|
||||||
|
assert limits.prompt_continue_limit("hit a wall") is True
|
||||||
|
assert auto_continue_enabled() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_limit_prompt_can_stop_the_turn_from_the_notification() -> None:
|
||||||
|
from plyngent.cli import limits
|
||||||
|
|
||||||
|
with temporary_backend(ScriptedBackend([])), _answered("n"):
|
||||||
|
assert limits.prompt_continue_limit("hit a wall") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_skill_write_can_be_allowed_for_the_chat_from_the_notification() -> None:
|
||||||
|
"""``s`` (this chat) is a button, not only a terminal token."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from plyngent.cli import limits
|
||||||
|
|
||||||
|
with temporary_backend(ScriptedBackend([])), _answered("s"):
|
||||||
|
decision = limits.prompt_skill_write_confirm(Path("/tmp/skill"), "write", "one line", 30.0)
|
||||||
|
assert decision.allow is True
|
||||||
|
assert decision.session is True
|
||||||
|
|
||||||
|
|
||||||
def test_every_confirm_announces_itself() -> None:
|
def test_every_confirm_announces_itself() -> None:
|
||||||
"""Each blocking confirm reports its kind, so a host can pick a surface."""
|
"""Each blocking confirm reports its kind, so a host can pick a surface."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
import types
|
||||||
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -9,6 +11,7 @@ from plyngent.cli.notify import (
|
|||||||
ALLOW,
|
ALLOW,
|
||||||
DENY,
|
DENY,
|
||||||
REVIEW,
|
REVIEW,
|
||||||
|
DesktopNotifierChannel,
|
||||||
DesktopRequest,
|
DesktopRequest,
|
||||||
WaitNotifier,
|
WaitNotifier,
|
||||||
detect_multiplexer,
|
detect_multiplexer,
|
||||||
@@ -150,6 +153,12 @@ def test_terminal_sequence_osc777_and_osc9() -> None:
|
|||||||
assert terminal_sequence("bell", "T", "M") == "\a"
|
assert terminal_sequence("bell", "T", "M") == "\a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_body_less_notification_states_its_title_once() -> None:
|
||||||
|
"""OSC 9 joins title and body with a colon, which must not dangle."""
|
||||||
|
assert terminal_sequence("osc9", "T", "") == "\x1b]9;T\x07"
|
||||||
|
assert terminal_sequence("osc777", "T", "") == "\x1b]777;notify;T;\x07"
|
||||||
|
|
||||||
|
|
||||||
def test_terminal_sequence_is_wrapped_for_tmux() -> None:
|
def test_terminal_sequence_is_wrapped_for_tmux() -> None:
|
||||||
seq = terminal_sequence("osc777", "T", "M", multiplexer="tmux")
|
seq = terminal_sequence("osc777", "T", "M", multiplexer="tmux")
|
||||||
assert seq.startswith("\x1bPtmux;\x1b")
|
assert seq.startswith("\x1bPtmux;\x1b")
|
||||||
@@ -257,6 +266,34 @@ def test_a_confirmation_offers_three_buttons() -> None:
|
|||||||
assert request.reply_field is False
|
assert request.reply_field is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_waiting_prompt_is_answerable_before_it_is_announced() -> None:
|
||||||
|
"""The offer is decided when the wait starts, not when the notification shows.
|
||||||
|
|
||||||
|
A notification only appears after the grace period, so a prompt that learned
|
||||||
|
about its answers from ``notify_desktop`` would already be blocked on stdin.
|
||||||
|
"""
|
||||||
|
desktop = _FakeDesktop()
|
||||||
|
notifier, _ = _notifier(desktop=desktop, grace_seconds=60.0)
|
||||||
|
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||||
|
assert handle.supports_actions is True
|
||||||
|
assert desktop.requests == [] # not announced yet
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_question_is_answerable_before_it_is_announced() -> None:
|
||||||
|
desktop = _FakeDesktop()
|
||||||
|
notifier, _ = _notifier(desktop=desktop, grace_seconds=60.0)
|
||||||
|
with notifier(PromptWait("ask", "Which branch?")) as handle:
|
||||||
|
assert handle.supports_actions is True
|
||||||
|
assert desktop.requests == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_answer_is_expected_without_the_capability() -> None:
|
||||||
|
desktop = _FakeDesktop(capabilities=frozenset({"buttons"}))
|
||||||
|
notifier, _ = _notifier(desktop=desktop)
|
||||||
|
with notifier(PromptWait("ask", "Which branch?")) as handle:
|
||||||
|
assert handle.supports_actions is False
|
||||||
|
|
||||||
|
|
||||||
def test_an_ask_offers_a_reply_field_instead_of_buttons() -> None:
|
def test_an_ask_offers_a_reply_field_instead_of_buttons() -> None:
|
||||||
desktop = _FakeDesktop()
|
desktop = _FakeDesktop()
|
||||||
notifier, _ = _notifier(desktop=desktop)
|
notifier, _ = _notifier(desktop=desktop)
|
||||||
@@ -267,13 +304,17 @@ def test_an_ask_offers_a_reply_field_instead_of_buttons() -> None:
|
|||||||
assert request.buttons == ()
|
assert request.buttons == ()
|
||||||
|
|
||||||
|
|
||||||
def test_a_limit_prompt_carries_no_answers() -> None:
|
def test_a_limit_prompt_can_be_answered() -> None:
|
||||||
|
"""A limit prompt blocks the turn too, so it offers buttons like a confirm."""
|
||||||
desktop = _FakeDesktop()
|
desktop = _FakeDesktop()
|
||||||
notifier, _ = _notifier(desktop=desktop)
|
notifier, _ = _notifier(desktop=desktop)
|
||||||
with notifier(PromptWait("limit", "Round limit reached")) as handle:
|
with notifier(PromptWait("limit", "Round limit reached")) as handle:
|
||||||
|
assert handle.supports_actions is True
|
||||||
handle._notify()
|
handle._notify()
|
||||||
assert handle.supports_actions is False
|
answer = desktop.requests[0].on_answer
|
||||||
assert desktop.requests[0].buttons == ()
|
assert answer is not None
|
||||||
|
answer(ALLOW)
|
||||||
|
assert handle.take() == ALLOW
|
||||||
assert desktop.requests[0].reply_field is False
|
assert desktop.requests[0].reply_field is False
|
||||||
|
|
||||||
|
|
||||||
@@ -351,6 +392,133 @@ def test_the_notification_is_dismissed_when_the_prompt_returns() -> None:
|
|||||||
assert desktop.cleared == ["id-1"]
|
assert desktop.cleared == ["id-1"]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# The desktop-notifier call itself
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class _Capability:
|
||||||
|
BUTTONS = "buttons"
|
||||||
|
REPLY_FIELD = "reply"
|
||||||
|
|
||||||
|
|
||||||
|
class _Urgency:
|
||||||
|
Critical = "critical"
|
||||||
|
Normal = "normal"
|
||||||
|
Low = "low"
|
||||||
|
|
||||||
|
|
||||||
|
class _Field:
|
||||||
|
"""A stand-in for ``Button`` / ``ReplyField`` (it only carries its keywords)."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs: object) -> None:
|
||||||
|
self.kwargs = kwargs
|
||||||
|
|
||||||
|
|
||||||
|
class _RecordingNotifier:
|
||||||
|
"""A stand-in for ``DesktopNotifier`` that records every send."""
|
||||||
|
|
||||||
|
sent: ClassVar[list[dict[str, object]]] = []
|
||||||
|
|
||||||
|
def __init__(self, app_name: str) -> None:
|
||||||
|
self.app_name = app_name
|
||||||
|
|
||||||
|
async def get_capabilities(self) -> frozenset[str]:
|
||||||
|
return frozenset({_Capability.BUTTONS})
|
||||||
|
|
||||||
|
async def send(self, **kwargs: object) -> str:
|
||||||
|
_RecordingNotifier.sent.append(kwargs)
|
||||||
|
return "id-1"
|
||||||
|
|
||||||
|
async def clear(self, identifier: str) -> None:
|
||||||
|
del identifier
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _stub_extra(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Put a stand-in ``desktop_notifier`` in place of the real extra."""
|
||||||
|
module = types.ModuleType("desktop_notifier")
|
||||||
|
module.Button = _Field # type: ignore[attr-defined]
|
||||||
|
module.ReplyField = _Field # type: ignore[attr-defined]
|
||||||
|
module.Capability = _Capability # type: ignore[attr-defined]
|
||||||
|
module.Urgency = _Urgency # type: ignore[attr-defined]
|
||||||
|
module.DesktopNotifier = _RecordingNotifier # type: ignore[attr-defined]
|
||||||
|
monkeypatch.setitem(sys.modules, "desktop_notifier", module)
|
||||||
|
_RecordingNotifier.sent.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_stub_extra")
|
||||||
|
def test_a_desktop_notification_is_kept_on_screen() -> None:
|
||||||
|
"""It must not collapse into the tray while the turn waits for an answer."""
|
||||||
|
channel = DesktopNotifierChannel()
|
||||||
|
identifier = channel.send(
|
||||||
|
DesktopRequest(
|
||||||
|
title="Allow?",
|
||||||
|
message="Allow?",
|
||||||
|
buttons=((ALLOW, "Allow once"),),
|
||||||
|
reply_field=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert identifier == "id-1"
|
||||||
|
sent = _RecordingNotifier.sent[-1]
|
||||||
|
assert sent["timeout"] == 0 # 0 = until dismissed; -1 = the server's few seconds
|
||||||
|
# Urgency stays at the library's default: no expiry is what keeps it up, and
|
||||||
|
# `critical` only adds a colour and breaks through a Focus mode.
|
||||||
|
assert "urgency" not in sent
|
||||||
|
assert sent["buttons"] and sent["reply_field"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_prompt_offers_its_own_answers() -> None:
|
||||||
|
"""The prompt's ``choices`` become the buttons, plus a way to the terminal."""
|
||||||
|
desktop = _FakeDesktop()
|
||||||
|
notifier, _ = _notifier(desktop=desktop)
|
||||||
|
choices = (("y", "Continue"), ("yyy", "Continue, stop asking"), ("n", "Stop here"))
|
||||||
|
with notifier(PromptWait("limit", "limit reached — continue?", "why", choices)) as handle:
|
||||||
|
handle._notify()
|
||||||
|
request = desktop.requests[0]
|
||||||
|
assert request.buttons == (*choices, (REVIEW, "Go to terminal"))
|
||||||
|
assert request.on_answer is not None
|
||||||
|
request.on_answer("yyy")
|
||||||
|
assert handle.take() == "yyy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_prompt_without_choices_gets_the_usual_buttons() -> None:
|
||||||
|
desktop = _FakeDesktop()
|
||||||
|
notifier, _ = _notifier(desktop=desktop)
|
||||||
|
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||||
|
handle._notify()
|
||||||
|
request = desktop.requests[0]
|
||||||
|
assert request.buttons == (
|
||||||
|
(ALLOW, "Allow once"),
|
||||||
|
(DENY, "Deny"),
|
||||||
|
(REVIEW, "Go to terminal"),
|
||||||
|
)
|
||||||
|
# Neither channel repeats the title in the body.
|
||||||
|
assert request.message == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_prompt_after_a_notification_answer_is_announced_at_once() -> None:
|
||||||
|
"""The deny comment follows the button press, so it must not wait again."""
|
||||||
|
desktop = _FakeDesktop()
|
||||||
|
notifier, _ = _notifier(desktop=desktop, grace_seconds=60.0, min_interval_seconds=600.0)
|
||||||
|
with notifier(PromptWait("confirm", "Allow?")) as handle:
|
||||||
|
handle._notify()
|
||||||
|
answer = desktop.requests[0].on_answer
|
||||||
|
assert answer is not None
|
||||||
|
answer(DENY)
|
||||||
|
with notifier(PromptWait("ask", "Why deny?")) as follow_up:
|
||||||
|
assert follow_up.handoff is True
|
||||||
|
deadline = time.monotonic() + 1.0
|
||||||
|
while len(desktop.requests) < 2 and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.01)
|
||||||
|
assert len(desktop.requests) == 2
|
||||||
|
# An unrelated prompt is throttled again, and waits out the grace period.
|
||||||
|
with notifier(PromptWait("confirm", "Allow?")) as later:
|
||||||
|
assert later.handoff is False
|
||||||
|
assert notifier.may_notify(later) is False
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Installation
|
# Installation
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -370,9 +538,10 @@ def test_install_notifier_can_be_disabled() -> None:
|
|||||||
def test_reason_stays_out_unless_asked_for() -> None:
|
def test_reason_stays_out_unless_asked_for() -> None:
|
||||||
notifier, _ = _notifier()
|
notifier, _ = _notifier()
|
||||||
wait = PromptWait("confirm", "Allow?", "argv: rm -rf /")
|
wait = PromptWait("confirm", "Allow?", "argv: rm -rf /")
|
||||||
assert notifier._message(wait) == "Allow?"
|
# The title has a field of its own; the body is empty without a reason.
|
||||||
|
assert notifier._message(wait) == ""
|
||||||
notifier, _ = _notifier(grace_seconds=0.0, include_reason=True)
|
notifier, _ = _notifier(grace_seconds=0.0, include_reason=True)
|
||||||
assert "rm -rf" in notifier._message(wait)
|
assert notifier._message(wait) == "argv: rm -rf /"
|
||||||
|
|
||||||
|
|
||||||
def test_temporary_backend_is_transparent_to_the_notifier() -> None:
|
def test_temporary_backend_is_transparent_to_the_notifier() -> None:
|
||||||
|
|||||||
@@ -135,10 +135,12 @@ access_key_or_token = "sk"
|
|||||||
lambda _p, **_kw: DummyClient(),
|
lambda _p, **_kw: DummyClient(),
|
||||||
)
|
)
|
||||||
|
|
||||||
from platformdirs import user_data_path
|
# The durable default must stay untouched, and the test must not read the
|
||||||
|
# host's own chat.db to prove it: point that default into tmp_path and check
|
||||||
global_db = user_data_path("plyngent") / "chat.db"
|
# nothing was created there.
|
||||||
global_mtime = global_db.stat().st_mtime if global_db.exists() else None
|
unused_data_dir = tmp_path / "user-data"
|
||||||
|
_ = unused_data_dir.mkdir()
|
||||||
|
monkeypatch.setattr("plyngent.cli.app.user_data_path", lambda *_args, **_kwargs: unused_data_dir)
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
@@ -162,8 +164,7 @@ access_key_or_token = "sk"
|
|||||||
assert result.exit_code == EXIT_OK
|
assert result.exit_code == EXIT_OK
|
||||||
assert "pong" in result.output
|
assert "pong" in result.output
|
||||||
assert db_path.is_file()
|
assert db_path.is_file()
|
||||||
if global_mtime is not None:
|
assert not (unused_data_dir / "chat.db").exists()
|
||||||
assert global_db.stat().st_mtime == global_mtime
|
|
||||||
|
|
||||||
|
|
||||||
def test_chat_help_mentions_prompt() -> None:
|
def test_chat_help_mentions_prompt() -> None:
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ from plyngent.lmproto.openai_compatible.model import (
|
|||||||
UserChatMessage,
|
UserChatMessage,
|
||||||
)
|
)
|
||||||
from plyngent.memory import MemoryStore
|
from plyngent.memory import MemoryStore
|
||||||
|
from plyngent.tools import image_input_refusal
|
||||||
|
from plyngent.tools.context import bind_instance
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
@@ -855,3 +857,23 @@ async def test_a_picture_mention_is_skipped_for_a_text_only_model(
|
|||||||
note = payload.notes[0]
|
note = payload.notes[0]
|
||||||
assert "does not accept image input" in note
|
assert "does not accept image input" in note
|
||||||
assert "image_in = true" in note
|
assert "image_in = true" in note
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_tool_image_gate_follows_the_selected_model(state: ReplState) -> None:
|
||||||
|
"""The image-carrying tools read the same verdict the mention gate uses."""
|
||||||
|
policy = state.instance_state.workspace
|
||||||
|
|
||||||
|
state.sync_image_capability()
|
||||||
|
assert policy.image_in is False
|
||||||
|
assert policy.image_model == (state.provider_name, state.model)
|
||||||
|
|
||||||
|
state.provider.models[state.model] = ModelConfig(image_in=True)
|
||||||
|
state.sync_image_capability()
|
||||||
|
assert policy.image_in is True
|
||||||
|
|
||||||
|
state.provider.models[state.model] = ModelConfig(image_in=False)
|
||||||
|
state.sync_image_capability()
|
||||||
|
with bind_instance(state.instance_state):
|
||||||
|
refusal = image_input_refusal()
|
||||||
|
assert refusal is not None
|
||||||
|
assert f'[providers.{state.provider_name}.models."{state.model}"]' in refusal
|
||||||
|
|||||||
@@ -267,6 +267,25 @@ def test_non_interactive_confirm_uses_default() -> None:
|
|||||||
assert confirm("ok?", default=True) is True
|
assert confirm("ok?", default=True) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_takes_a_host_answer() -> None:
|
||||||
|
"""``allow`` / ``deny`` from a notification answer a yes/no like ``y`` / ``n``."""
|
||||||
|
with temporary_backend(ScriptedBackend([])):
|
||||||
|
assert confirm("Go?", source=_AnswerSource("allow")) is True
|
||||||
|
assert confirm("Go?", source=_AnswerSource("deny")) is False
|
||||||
|
assert confirm("Go?", source=_AnswerSource("n")) is False
|
||||||
|
# Nothing arrived: the prompt falls back to its default.
|
||||||
|
assert confirm("Go?", default=True, source=_AnswerSource(None)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_takes_an_answer_the_hook_offers() -> None:
|
||||||
|
backend = ScriptedBackend([], confirms=[False])
|
||||||
|
set_prompt_wait_hook(lambda _wait: contextlib.nullcontext(_AnswerSource("allow")))
|
||||||
|
with temporary_backend(backend):
|
||||||
|
assert confirm("Go?") is True
|
||||||
|
# The answer came from the surface, so the backend was never asked.
|
||||||
|
assert backend.confirms == [False]
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_output_hook_runs_before_chrome(capsys: pytest.CaptureFixture[str]) -> None:
|
def test_prompt_output_hook_runs_before_chrome(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
"""A registered hook runs before echo/secho write; clearing it removes it.
|
"""A registered hook runs before echo/secho write; clearing it removes it.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from plyngent.tools.command_scan import basename, escalators_in_chain, is_interpreter, unwrap_command
|
from plyngent.tools.command_scan import basename, dash_c_code, escalators_in_chain, is_interpreter, unwrap_command
|
||||||
|
|
||||||
|
|
||||||
def test_basename() -> None:
|
def test_basename() -> None:
|
||||||
@@ -132,6 +132,92 @@ def test_unwrap_empty_argv() -> None:
|
|||||||
assert scan.command == ()
|
assert scan.command == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unwrap_remote_command() -> None:
|
||||||
|
"""``ssh``/``mosh`` are unwrapped to the program they run on the far end."""
|
||||||
|
scan = unwrap_command(["ssh", "prod", "rm", "-rf", "/"])
|
||||||
|
assert scan.wrappers == ("ssh",)
|
||||||
|
assert scan.base == "rm"
|
||||||
|
assert scan.command == ("rm", "-rf", "/")
|
||||||
|
assert scan.remote is not None
|
||||||
|
assert (scan.remote.wrapper, scan.remote.host, scan.remote.batch) == ("ssh", "prod", False)
|
||||||
|
|
||||||
|
scan = unwrap_command(["ssh", "-p", "2222", "-i", "~/.ssh/id", "user@prod", "ls", "-la"])
|
||||||
|
assert scan.base == "ls"
|
||||||
|
assert scan.command == ("ls", "-la")
|
||||||
|
assert scan.remote is not None and scan.remote.host == "user@prod"
|
||||||
|
|
||||||
|
# Jammed values, and a hop behind a local wrapper.
|
||||||
|
scan = unwrap_command(["ssh", "-p2222", "prod", "uptime"])
|
||||||
|
assert scan.base == "uptime"
|
||||||
|
assert unwrap_command(["sudo", "ssh", "prod", "id"]).base == "id"
|
||||||
|
scan = unwrap_command(["mosh", "prod", "--", "htop"])
|
||||||
|
assert scan.base == "htop"
|
||||||
|
assert scan.remote is not None and scan.remote.wrapper == "mosh"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unwrap_remote_batch_mode() -> None:
|
||||||
|
"""``-o BatchMode=yes`` is reported: the code runs with no prompt at either end."""
|
||||||
|
for argv in (
|
||||||
|
["ssh", "-o", "BatchMode=yes", "prod", "deploy.sh"],
|
||||||
|
["ssh", "-oBatchMode=yes", "prod", "deploy.sh"],
|
||||||
|
["ssh", "-o", "batchmode=YES", "-p", "22", "prod", "deploy.sh"],
|
||||||
|
):
|
||||||
|
scan = unwrap_command(argv)
|
||||||
|
assert scan.remote is not None, argv
|
||||||
|
assert scan.remote.batch is True, argv
|
||||||
|
scan = unwrap_command(["ssh", "-o", "BatchMode=no", "prod", "deploy.sh"])
|
||||||
|
assert scan.remote is not None and scan.remote.batch is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_unwrap_remote_client_alone() -> None:
|
||||||
|
"""A client with no command opens a remote shell: nothing to unwrap."""
|
||||||
|
for argv in (["ssh", "prod"], ["ssh", "-N", "prod"], ["ssh", "-G", "prod"], ["mosh", "prod"]):
|
||||||
|
scan = unwrap_command(argv)
|
||||||
|
assert scan.wrappers == (), argv
|
||||||
|
assert scan.base == argv[0], argv
|
||||||
|
assert scan.remote is not None, argv
|
||||||
|
assert scan.remote.shell is True, argv
|
||||||
|
assert scan.remote.host == "prod", argv
|
||||||
|
# Without a destination there is no hop to announce.
|
||||||
|
for argv in (["ssh"], ["ssh", "-V"]):
|
||||||
|
assert unwrap_command(argv).remote is None, argv
|
||||||
|
|
||||||
|
|
||||||
|
def test_unwrap_reads_options_with_getopt() -> None:
|
||||||
|
"""Each wrapper's spec is a ``getopt`` pattern: flags and values are exact.
|
||||||
|
|
||||||
|
``--`` ends the wrapper's options, jammed values work (``-p2222``), and a
|
||||||
|
launcher's common flags stay flags (``uv run --no-project pytest``).
|
||||||
|
"""
|
||||||
|
assert unwrap_command(["sudo", "-u", "root", "--", "rm", "-rf", "/"]).base == "rm"
|
||||||
|
assert unwrap_command(["ssh", "-p2222", "prod", "uptime"]).base == "uptime"
|
||||||
|
assert unwrap_command(["uv", "run", "--no-project", "pytest", "-x"]).base == "pytest"
|
||||||
|
assert unwrap_command(["time", "-f", "%e", "python", "x.py"]).base == "python"
|
||||||
|
assert unwrap_command(["xargs", "-0", "--", "rm"]).base == "rm"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_option_stops_the_unwrap_and_is_reported() -> None:
|
||||||
|
"""The program behind an option we cannot read is a guess, so it is reported.
|
||||||
|
|
||||||
|
The wrapper itself stays the command: ``sudo <new flag> rm -rf /`` is still
|
||||||
|
judged as ``sudo`` by the denylist, so nothing hides behind the guess.
|
||||||
|
"""
|
||||||
|
scan = unwrap_command(["sudo", "--new-sudo-flag", "rm", "-rf", "/"])
|
||||||
|
assert scan.base == "sudo", scan
|
||||||
|
assert scan.wrappers == ()
|
||||||
|
assert "new-sudo-flag" in scan.unparsed
|
||||||
|
# A known option is read, not reported.
|
||||||
|
assert unwrap_command(["sudo", "-E", "rm", "-rf", "/"]).unparsed == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_unwrap_remote_command_still_reviews_what_runs() -> None:
|
||||||
|
"""The remote slice is parsed like a local command (code, escalation)."""
|
||||||
|
scan = unwrap_command(["ssh", "prod", "bash", "-c", "rm -rf /"])
|
||||||
|
assert scan.base == "bash"
|
||||||
|
assert dash_c_code(scan.command) == (1, "rm -rf /")
|
||||||
|
assert escalators_in_chain(["ssh", "prod", "sudo", "rm", "-rf", "/"]) == ("sudo",)
|
||||||
|
|
||||||
|
|
||||||
def test_is_interpreter_ignores_version_suffix() -> None:
|
def test_is_interpreter_ignores_version_suffix() -> None:
|
||||||
assert is_interpreter("python")
|
assert is_interpreter("python")
|
||||||
assert is_interpreter("python3.12")
|
assert is_interpreter("python3.12")
|
||||||
|
|||||||
@@ -160,6 +160,58 @@ def test_versioned_interpreter_is_still_reviewed() -> None:
|
|||||||
assert "import os" in reason
|
assert "import os" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_remote_command_is_judged_by_what_runs_there() -> None:
|
||||||
|
"""``ssh``/``mosh`` do not hide the program from the review."""
|
||||||
|
# The remote slice decides, exactly like a local command.
|
||||||
|
plain = _reason("run_argv", {"argv": ["ssh", "prod", "uptime"]})
|
||||||
|
assert plain is None
|
||||||
|
code = _reason("run_argv", {"argv": ["ssh", "prod", "bash", "-c", "rm -rf /"]})
|
||||||
|
assert code is not None
|
||||||
|
assert "bash -c" in code
|
||||||
|
assert "rm -rf" in code
|
||||||
|
# And it is the same confirm through a PTY session.
|
||||||
|
pty = _reason("open_pty", {"command": ["ssh", "prod", "python3"]})
|
||||||
|
assert pty is not None and "python3" in pty
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unreadable_option_asks_instead_of_guessing() -> None:
|
||||||
|
"""An option the spec does not know makes the wrapped program a guess."""
|
||||||
|
reason = _reason("run_argv", {"argv": ["sudo", "--new-sudo-flag", "uptime"]})
|
||||||
|
assert reason is not None
|
||||||
|
assert "options not understood" in reason
|
||||||
|
assert "new-sudo-flag" in reason
|
||||||
|
# A known option is read: no review for a harmless wrapped program.
|
||||||
|
assert _reason("run_argv", {"argv": ["sudo", "-E", "uptime"]}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_remote_shell_is_announced() -> None:
|
||||||
|
"""``ssh host`` runs nothing this scan can see, so the human is told first."""
|
||||||
|
reason = _reason("open_pty", {"command": ["ssh", "prod"]})
|
||||||
|
assert reason is not None
|
||||||
|
assert "remote shell on 'prod'" in reason
|
||||||
|
assert "input is not checked" in reason
|
||||||
|
# The same for a one-shot client: stdin is another unchecked input channel.
|
||||||
|
assert "remote shell on 'prod'" in (_reason("run_argv", {"argv": ["ssh", "-p", "2222", "prod"]}) or "")
|
||||||
|
# A mosh shell counts too, and a hop with nothing after it is not silent.
|
||||||
|
assert "remote shell" in (_reason("open_pty", {"command": ["mosh", "prod"]}) or "")
|
||||||
|
# A remote *command* keeps its own review (no shell wording).
|
||||||
|
assert "remote shell" not in (_reason("run_argv", {"argv": ["ssh", "prod", "uptime"]}) or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_mode_is_always_reviewed() -> None:
|
||||||
|
"""``BatchMode=yes`` cannot prompt at either end, so the command is shown."""
|
||||||
|
reason = _reason("run_argv", {"argv": ["ssh", "-o", "BatchMode=yes", "prod", "deploy.sh"]})
|
||||||
|
assert reason is not None
|
||||||
|
assert "BatchMode=yes" in reason
|
||||||
|
assert "deploy.sh" in reason
|
||||||
|
assert _danger("run_argv", {"argv": ["ssh", "-oBatchMode=yes", "prod", "deploy.sh"]}).yolo_skip is True
|
||||||
|
# An escalation behind the hop still never skips the confirm under YOLO.
|
||||||
|
escalated = _danger("run_argv", {"argv": ["ssh", "prod", "sudo", "python3", "-c", "x=1"]})
|
||||||
|
assert escalated.yolo_skip is False
|
||||||
|
assert "escalation sudo" in escalated.text
|
||||||
|
assert "interpreter 'python3'" in escalated.text
|
||||||
|
|
||||||
|
|
||||||
def test_wrappers_reach_the_interpreter() -> None:
|
def test_wrappers_reach_the_interpreter() -> None:
|
||||||
for argv in (
|
for argv in (
|
||||||
["env", "FOO=1", "python", "-c", "print(1)"],
|
["env", "FOO=1", "python", "-c", "print(1)"],
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, override
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from plyngent.agent import ToolOutput
|
from plyngent.agent import ToolOutput
|
||||||
from plyngent.tools.context import read_only_context
|
from plyngent.tools.context import get_instance, read_only_context
|
||||||
from plyngent.tools.danger import classify_danger
|
from plyngent.tools.danger import classify_danger
|
||||||
from plyngent.tools.net import fetch_local, grant_private_host
|
from plyngent.tools.net import fetch_local, grant_private_host
|
||||||
from plyngent.tools.net.policy import (
|
from plyngent.tools.net.policy import (
|
||||||
@@ -225,6 +225,24 @@ async def test_an_image_response_comes_back_as_the_picture(workspace: Path, http
|
|||||||
assert part.image_url.url.startswith("data:image/png;base64,")
|
assert part.image_url.url.startswith("data:image/png;base64,")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_text_only_model_keeps_the_metadata_and_the_reason(
|
||||||
|
workspace: Path,
|
||||||
|
http_server: str,
|
||||||
|
) -> None:
|
||||||
|
"""The picture stays behind, and the model is told why (``image_in``)."""
|
||||||
|
grant_private_host("127.0.0.1", int(http_server.rsplit(":", 1)[1]))
|
||||||
|
instance = get_instance()
|
||||||
|
assert instance is not None
|
||||||
|
instance.workspace.image_in = False
|
||||||
|
instance.workspace.image_model = ("ds", "text-only")
|
||||||
|
|
||||||
|
output = await fetch_local.handler(f"{http_server}/png")
|
||||||
|
|
||||||
|
assert isinstance(output, str)
|
||||||
|
assert "body_kind=image" in output
|
||||||
|
assert "error: model 'text-only' does not accept image input" in output
|
||||||
|
|
||||||
|
|
||||||
async def test_fetch_read_only_context_blocks_non_get(workspace: Path, http_server: str) -> None:
|
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."""
|
"""In a read-only context fetch_local only allows GET; outside it POST works."""
|
||||||
del workspace
|
del workspace
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import asyncio
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from plyngent.tools.process import (
|
from plyngent.tools.process import (
|
||||||
ask_into_pty,
|
ask_into_pty,
|
||||||
close_pty,
|
close_pty,
|
||||||
@@ -514,13 +516,76 @@ def test_decode_write_data_escapes() -> None:
|
|||||||
from plyngent.tools.process.pty_terminal import decode_write_data
|
from plyngent.tools.process.pty_terminal import decode_write_data
|
||||||
|
|
||||||
assert decode_write_data(r"\x0f") == "\x0f"
|
assert decode_write_data(r"\x0f") == "\x0f"
|
||||||
assert decode_write_data("ctrl+x") == "\x18"
|
|
||||||
assert decode_write_data("CTRL+O") == "\x0f"
|
|
||||||
assert decode_write_data(r"\e") == "\x1b"
|
assert decode_write_data(r"\e") == "\x1b"
|
||||||
assert decode_write_data("key=esc") == "\x1b"
|
|
||||||
assert decode_write_data("key=enter") == "\r"
|
|
||||||
assert decode_write_data(r"a\nb") == "a\nb"
|
assert decode_write_data(r"a\nb") == "a\nb"
|
||||||
assert decode_write_data("plain") == "plain"
|
assert decode_write_data("plain") == "plain"
|
||||||
|
# Markers: names, modifiers, repeats, and a literal `<`.
|
||||||
|
assert decode_write_data("<esc>") == "\x1b"
|
||||||
|
assert decode_write_data("<ESC>") == "\x1b"
|
||||||
|
assert decode_write_data("<enter>") == "\r"
|
||||||
|
assert decode_write_data("<s-tab>") == "\x1b[Z"
|
||||||
|
assert decode_write_data("<c-x>") == "\x18"
|
||||||
|
assert decode_write_data("<C-O>") == "\x0f"
|
||||||
|
assert decode_write_data("<c-[>") == "\x1b"
|
||||||
|
assert decode_write_data("<m-b>") == "\x1bb"
|
||||||
|
assert decode_write_data("<m-tab>") == "\x1b\t"
|
||||||
|
assert decode_write_data("<up*3>") == "\x1b[A" * 3
|
||||||
|
assert decode_write_data("<<value>") == "<value>"
|
||||||
|
assert decode_write_data("<home>") == "\x1b[H"
|
||||||
|
assert decode_write_data("<ins>") == "\x1b[2~"
|
||||||
|
assert decode_write_data("<bs>") == "\x7f"
|
||||||
|
assert decode_write_data("<pgup>") == "\x1b[5~"
|
||||||
|
assert decode_write_data("<f5>") == "\x1b[15~"
|
||||||
|
# A modifier on a key that has one: xterm's 1 + shift/alt/ctrl parameter.
|
||||||
|
assert decode_write_data("<s-up>") == "\x1b[1;2A"
|
||||||
|
assert decode_write_data("<m-up>") == "\x1b[1;3A"
|
||||||
|
assert decode_write_data("<c-up>") == "\x1b[1;5A"
|
||||||
|
assert decode_write_data("<a-left>") == "\x1b[1;3D"
|
||||||
|
assert decode_write_data("<s-pgdn>") == "\x1b[6;2~"
|
||||||
|
assert decode_write_data("<c-del>") == "\x1b[3;5~"
|
||||||
|
assert decode_write_data("<c-s-f5>") == "\x1b[15;6~"
|
||||||
|
assert decode_write_data("<c-m-home>") == "\x1b[1;7H"
|
||||||
|
assert decode_write_data("<c-space>") == "\x00"
|
||||||
|
# A `<` that does not open a marker stays literal, spaces included.
|
||||||
|
assert decode_write_data("sort < file") == "sort < file"
|
||||||
|
assert decode_write_data("a < b > c") == "a < b > c"
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_write_data_refuses_an_unknown_key() -> None:
|
||||||
|
"""A typo must be an error, not text typed into the terminal."""
|
||||||
|
from plyngent.tools.process.pty_terminal import KeySyntaxError, decode_write_data
|
||||||
|
|
||||||
|
for data in ("<hme>", "<c-1>", "<up*x>", "<up*0>", "<c->", "<s-a>", "<c-tab>", "<c-s-tab>"):
|
||||||
|
with pytest.raises(KeySyntaxError):
|
||||||
|
_ = decode_write_data(data)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_write_pty_keys_sends_modified_keys(workspace: object) -> None:
|
||||||
|
"""`<c-up><s-tab>` leaves the session as the bytes a terminal would send."""
|
||||||
|
del workspace
|
||||||
|
try:
|
||||||
|
opened = await call_async(
|
||||||
|
open_pty,
|
||||||
|
_py(
|
||||||
|
"import sys, tty\n"
|
||||||
|
"tty.setraw(0)\n"
|
||||||
|
"sys.stdout.write('READY\\n')\n"
|
||||||
|
"sys.stdout.flush()\n"
|
||||||
|
"data = sys.stdin.buffer.read(9)\n"
|
||||||
|
"sys.stdout.write('GOT ' + repr(data))\n"
|
||||||
|
"sys.stdout.flush()\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session_id = _session_id(opened)
|
||||||
|
# Raw mode first: a cooked PTY would hold the keys until a newline.
|
||||||
|
_ = await call_async(read_pty, session_id, timeout=5.0, until="READY")
|
||||||
|
written = await call_async(write_pty_keys, session_id, "<c-up><s-tab>")
|
||||||
|
assert "wrote=9" in written
|
||||||
|
text = await call_async(read_pty, session_id, timeout=2.0, until="GOT ")
|
||||||
|
assert "GOT " + repr(b"\x1b[1;5A\x1b[Z") in text
|
||||||
|
_ = await call_async(close_pty, session_id)
|
||||||
|
finally:
|
||||||
|
PtyManager.close_all()
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_pty_output_escapes_csi() -> None:
|
def test_sanitize_pty_output_escapes_csi() -> None:
|
||||||
@@ -567,7 +632,7 @@ async def test_write_pty_keys_ctrl_escape(workspace: object) -> None:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
session_id = _session_id(opened)
|
session_id = _session_id(opened)
|
||||||
written = await call_async(write_pty_keys, session_id, "ctrl+c")
|
written = await call_async(write_pty_keys, session_id, "<c-c>")
|
||||||
assert "wrote=1" in written
|
assert "wrote=1" in written
|
||||||
text = await call_async(read_pty, session_id, timeout=2.0)
|
text = await call_async(read_pty, session_id, timeout=2.0)
|
||||||
assert "error" not in text.lower() or "alive=" in text
|
assert "error" not in text.lower() or "alive=" in text
|
||||||
@@ -576,13 +641,26 @@ async def test_write_pty_keys_ctrl_escape(workspace: object) -> None:
|
|||||||
PtyManager.close_all()
|
PtyManager.close_all()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_write_pty_keys_refuses_an_unknown_marker(workspace: object) -> None:
|
||||||
|
"""The typo comes back as an error and never reaches the session."""
|
||||||
|
del workspace
|
||||||
|
try:
|
||||||
|
opened = await call_async(open_pty, _py("print('up')"))
|
||||||
|
session_id = _session_id(opened)
|
||||||
|
result = await call_async(write_pty_keys, session_id, "<hme>")
|
||||||
|
assert result.startswith("error: unknown key <hme>")
|
||||||
|
_ = await call_async(close_pty, session_id)
|
||||||
|
finally:
|
||||||
|
PtyManager.close_all()
|
||||||
|
|
||||||
|
|
||||||
def test_write_pty_is_literal() -> None:
|
def test_write_pty_is_literal() -> None:
|
||||||
"""Plain write_pty must not call the keys decoder."""
|
"""Plain write_pty must not call the keys decoder."""
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
from plyngent.tools.process.pty_terminal import decode_write_data
|
from plyngent.tools.process.pty_terminal import decode_write_data
|
||||||
|
|
||||||
assert decode_write_data("press ctrl+c to cancel") == "press " + chr(3) + " to cancel"
|
assert decode_write_data("press <c-c> to cancel") == "press " + chr(3) + " to cancel"
|
||||||
src = inspect.getsource(write_pty.handler)
|
src = inspect.getsource(write_pty.handler)
|
||||||
assert "decode_write_data" not in src
|
assert "decode_write_data" not in src
|
||||||
doc = write_pty.description or write_pty.handler.__doc__ or ""
|
doc = write_pty.description or write_pty.handler.__doc__ or ""
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import base64
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from plyngent.agent import ToolOutput
|
from plyngent.agent import ToolOutput
|
||||||
|
from plyngent.tools.context import get_instance
|
||||||
from plyngent.tools.file.read import read_file
|
from plyngent.tools.file.read import read_file
|
||||||
from plyngent.tools.file.view_image import view_image
|
from plyngent.tools.file.view_image import view_image
|
||||||
|
|
||||||
@@ -60,6 +61,21 @@ async def test_a_missing_file_is_an_error(workspace: Path) -> None:
|
|||||||
assert await view_image.handler("nope.png") == "error: file not found: nope.png"
|
assert await view_image.handler("nope.png") == "error: file not found: nope.png"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_text_only_model_gets_the_reason_instead_of_the_picture(workspace: Path) -> None:
|
||||||
|
"""``image_in`` gates the tool exactly like an ``@path`` mention."""
|
||||||
|
(workspace / "shot.png").write_bytes(PNG)
|
||||||
|
instance = get_instance()
|
||||||
|
assert instance is not None
|
||||||
|
instance.workspace.image_in = False
|
||||||
|
instance.workspace.image_model = ("deepseek", "deepseek-v4-pro")
|
||||||
|
|
||||||
|
result = await view_image.handler("shot.png")
|
||||||
|
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert result.startswith("error: model 'deepseek-v4-pro' does not accept image input")
|
||||||
|
assert 'set image_in = true under [providers.deepseek.models."deepseek-v4-pro"]' in result
|
||||||
|
|
||||||
|
|
||||||
async def test_read_file_points_at_view_image_for_a_picture(workspace: Path) -> None:
|
async def test_read_file_points_at_view_image_for_a_picture(workspace: Path) -> None:
|
||||||
(workspace / "shot.png").write_bytes(PNG)
|
(workspace / "shot.png").write_bytes(PNG)
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ from plyngent.tools import (
|
|||||||
check_command_allowed,
|
check_command_allowed,
|
||||||
get_trash_enabled,
|
get_trash_enabled,
|
||||||
get_workspace_root,
|
get_workspace_root,
|
||||||
|
image_input_refusal,
|
||||||
mode_covers,
|
mode_covers,
|
||||||
parse_access_mode,
|
parse_access_mode,
|
||||||
resolve_path,
|
resolve_path,
|
||||||
set_command_denylist,
|
set_command_denylist,
|
||||||
|
set_image_in,
|
||||||
set_path_denylist,
|
set_path_denylist,
|
||||||
set_trash_enabled,
|
set_trash_enabled,
|
||||||
)
|
)
|
||||||
@@ -67,6 +69,34 @@ def test_trash_switch(workspace: object) -> None:
|
|||||||
assert get_trash_enabled() is True
|
assert get_trash_enabled() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_switch(workspace: object) -> None:
|
||||||
|
"""A picture is refused once the host says the model cannot take one."""
|
||||||
|
del workspace
|
||||||
|
assert image_input_refusal() is None
|
||||||
|
|
||||||
|
set_image_in(image_in=False)
|
||||||
|
unnamed = image_input_refusal()
|
||||||
|
assert unnamed is not None
|
||||||
|
assert "does not accept image input" in unnamed
|
||||||
|
|
||||||
|
set_image_in(image_in=False, provider="ds", model="text-only")
|
||||||
|
named = image_input_refusal()
|
||||||
|
assert named is not None
|
||||||
|
assert "model 'text-only' does not accept image input" in named
|
||||||
|
assert '[providers.ds.models."text-only"]' in named
|
||||||
|
|
||||||
|
set_image_in(image_in=True, provider="ds", model="vision")
|
||||||
|
assert image_input_refusal() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_image_switch_needs_a_declaration(workspace: object) -> None:
|
||||||
|
"""No host verdict (``None``) sends the picture: only ``False`` refuses."""
|
||||||
|
del workspace
|
||||||
|
policy = active_workspace_policy()
|
||||||
|
assert policy.image_in is None
|
||||||
|
assert image_input_refusal() is None
|
||||||
|
|
||||||
|
|
||||||
def test_config_grant_modes(workspace: object, tmp_path_factory: pytest.TempPathFactory) -> None:
|
def test_config_grant_modes(workspace: object, tmp_path_factory: pytest.TempPathFactory) -> None:
|
||||||
del workspace
|
del workspace
|
||||||
outside = tmp_path_factory.mktemp("granted")
|
outside = tmp_path_factory.mktemp("granted")
|
||||||
@@ -282,6 +312,26 @@ def test_command_denylist_reaches_through_wrappers(workspace: object) -> None:
|
|||||||
clear_policy_allowed_commands()
|
clear_policy_allowed_commands()
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_denylist_reaches_the_far_end_of_ssh(workspace: object) -> None:
|
||||||
|
"""A remote command is judged by the program that runs there, not by ``ssh``."""
|
||||||
|
del workspace
|
||||||
|
from plyngent.tools.workspace import clear_policy_allowed_commands, set_policy_confirm_hook
|
||||||
|
|
||||||
|
set_policy_confirm_hook(None)
|
||||||
|
clear_policy_allowed_commands()
|
||||||
|
try:
|
||||||
|
with pytest.raises(WorkspaceError, match="basename 'rm' is blocked"):
|
||||||
|
check_command_allowed(["ssh", "prod", "rm", "-rf", "/"])
|
||||||
|
with pytest.raises(WorkspaceError, match="basename 'sudo' is blocked"):
|
||||||
|
check_command_allowed(["ssh", "-p", "2222", "prod", "sudo", "systemctl", "restart", "web"])
|
||||||
|
# A remote shell (no command) and harmless remote commands stay allowed.
|
||||||
|
check_command_allowed(["ssh", "prod"])
|
||||||
|
check_command_allowed(["ssh", "-o", "BatchMode=yes", "prod", "uptime"])
|
||||||
|
check_command_allowed(["mosh", "prod", "--", "htop"])
|
||||||
|
finally:
|
||||||
|
clear_policy_allowed_commands()
|
||||||
|
|
||||||
|
|
||||||
def test_command_denylist_matches_lowercased_basename(workspace: object) -> None:
|
def test_command_denylist_matches_lowercased_basename(workspace: object) -> None:
|
||||||
del workspace
|
del workspace
|
||||||
from plyngent.tools.workspace import clear_policy_allowed_commands, set_policy_confirm_hook
|
from plyngent.tools.workspace import clear_policy_allowed_commands, set_policy_confirm_hook
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ class Capability(Enum):
|
|||||||
THREAD = ...
|
THREAD = ...
|
||||||
TIMEOUT = ...
|
TIMEOUT = ...
|
||||||
|
|
||||||
|
class Urgency(Enum):
|
||||||
|
Critical = ...
|
||||||
|
Normal = ...
|
||||||
|
Low = ...
|
||||||
|
|
||||||
class Button:
|
class Button:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -50,7 +55,7 @@ class DesktopNotifier:
|
|||||||
self,
|
self,
|
||||||
title: str,
|
title: str,
|
||||||
message: str,
|
message: str,
|
||||||
urgency: Any = ...,
|
urgency: Urgency = ...,
|
||||||
icon: Any = ...,
|
icon: Any = ...,
|
||||||
buttons: Sequence[Button] = ...,
|
buttons: Sequence[Button] = ...,
|
||||||
reply_field: ReplyField | None = None,
|
reply_field: ReplyField | None = None,
|
||||||
|
|||||||
Reference in New Issue
Block a user