35 Commits

Author SHA1 Message Date
NCBM caecf62f47 release: plyngent 0.13.0 2026-10-24 03:24:55 +08:00
NCBM 102c377adc deps: bump ruff to 0.17.2
The commit gateway pins its hooks to the same versions as pdm.lock (see
prek.toml), so both ruff pins move together and `pdm lock` follows.
0.17.2 finds nothing new in src or tests, and `prek run --all-files` is green
on it.
2026-10-24 03:24:44 +08:00
NCBM beaaaea2d9 deps: refresh the lock
`pdm lock` re-resolved within the existing constraints and picked up
sqlalchemy 2.1.5 → 2.1.6 and wassima 2.1.4 → 2.1.5; `pdm sync` installed
both, and the suite is green on them.
2026-10-24 03:21:16 +08:00
NCBM 475f7da645 test: keep the one-shot test off the host's chat.db
`test_chat_oneshot_success` proved that an explicit `[database].url` wins over
the durable default by stat'ing the *real* `~/.local/share/plyngent/chat.db` and
comparing its mtime. So a test read the user's own session database, and it went
red whenever a live chat happened to write it — which is what a run does while
another plyngent session is open.

The default path is now pointed into `tmp_path`, the way `test_app.py` already
does it, and the assertion is that no `chat.db` shows up there. Nothing outside
tmp_path is read, and a regression — the explicit url ignored — still fails,
because the default branch would create that file.
2026-10-24 02:19:39 +08:00
NCBM ce4fa80176 test: cover the payload a write_pty line shows
Pins the escaped form (a newline must not break the line), the 40-character cut
with its ellipsis, and the keys line keeping its markers.
2026-10-24 02:13:50 +08:00
NCBM 36e37e2a6f core/cli/display: show what a literal PTY write sends
`write_pty` printed only `* PTY Write 3 (5 chars)`, so the one line describing
typed text said nothing about it — the keys line at least named its markers.

Both write tools now share one prefix: the payload, rendered with `repr` so it
stays on one line and shows what is invisible (`'ls -la'`, `'a\nb'`, an
all-spaces payload that is not mistaken for an empty one), shortened to 40
characters. `ask_into_pty` keeps the payload-less line: the human's answer is
the one thing that must not be shown.
2026-10-24 02:13:37 +08:00
NCBM 280daa651d docs: name the modifier markers
The marker lists stopped at the unmodified keys, so `<c-up>` and `<c-s-f5>` —
the shapes a TUI actually needs — were not written down anywhere.
2026-10-24 02:08:57 +08:00
NCBM 6e69e5046c test: cover the modifier combinations
Pins the parameter arithmetic on a few shapes (shift, alt, ctrl, shift+ctrl,
ctrl+alt), the Alt-without-a-CSI-form case, Ctrl+Space as NUL, and the three
combinations that stay errors (`<s-a>`, `<c-tab>`, `<c-s-tab>`).
2026-10-24 02:08:57 +08:00
NCBM 87f7166a77 core/tools: spell modified keys the way xterm does
The marker table only knew unmodified keys, so `<s-up>` and `<c-f5>` were
errors and the fallback was raw bytes: a TUI that wants Shift+Tab and
Ctrl+arrows could not be driven without hand-writing `\x1b[1;5A`.

A marker may now carry `c-`, `m-` (or `a-`), and `s-`, alone or combined, and
keys that have an xterm form — arrows, Home/End, Ins/Del, PgUp/PgDn, F1–F12 —
expand to the CSI sequence with the 1 + shift/alt/ctrl parameter. `<m-up>` is
`\x1b[1;3A`, not an ESC in front of an arrow, which no terminal sends. On a
plain character the old spellings stay: Ctrl is the control byte, Alt an ESC
before the character, and Alt alone still prefixes a key without a CSI form
(`<m-tab>`). `<ins>`, `<insert>`, and Ctrl+Space (NUL) come along; a modifier a
key has no sequence for is still an error rather than a guess.
2026-10-24 02:06:51 +08:00
NCBM 1c319f13b6 docs: write the key markers down
Both places that described `write_pty_keys` named the old three-notation grammar
(`\xHH`, `ctrl+x`, `key=esc`), which no longer parses.
2026-10-24 02:03:52 +08:00
NCBM dbb68e7273 test: cover the key markers and the keys line
Pins the markers that used to be unreachable (s-tab, home, bs, pgup, f5, a
repeat, a literal `<`), the two shapes of a wrong marker (unknown name, bad
repeat) coming back as an error from the tool instead of being typed, and the
pretty line naming the keys it sent.
2026-10-24 02:03:40 +08:00
NCBM a2d8159267 core/cli/display: show what write_pty_keys sends
Its pretty line read `* PTY Keys 3 (1 char)` — the one tool whose whole job is
choosing keys was the one that never said which. The markers are already
readable, so the payload goes into the prefix as `* PTY Keys 3 '<c-c><enter>'`,
flattened and shortened like the other subject previews; the outcome stays the
byte count.
2026-10-24 02:03:26 +08:00
NCBM 9c6222d742 core/tools: read keys as <name> markers in write_pty_keys
The grammar had grown three notations for the same thing — a backslash escape
(`\e`, `\t`, `\x1b`), a `ctrl+x` name, and a `key=esc|up|…` marker — so ESC had
five spellings and code and docs already disagreed on one of them
(`key=escape`). `key=` also looked uncomposable while being the only way to
send a key twice (`key=upkey=up`), and anything unrecognised was written to the
terminal verbatim: `key=hme` typed those seven characters into a shell prompt,
in a tool that never prompts.

Markers are now one form — `<name>`, `<<` for a literal `<` — with named keys
through `<f12>`, Ctrl and Alt as `<c-a>` / `<m-x>`, `s-tab` for the reverse Tab
every TUI uses, and `<up*3>` to repeat. The name table lives in one place, and
an unknown or badly repeated marker raises instead of being typed: a typo is an
error the model can fix, not bytes the human has to notice. `ctrl+` and `key=`
are gone rather than kept as aliases, so nothing has two spellings again.
2026-10-24 02:03:13 +08:00
NCBM 8cede4cbb3 docs: say what a notification body carries
The page explained what stays out of the announcement but not what is left in
it, so a reader could not tell whether the same line appears twice.
2026-10-24 01:54:05 +08:00
NCBM 4845455d4b test: cover the body a prompt notification carries
Pins the two shapes: no body at all without the reason, the reason itself (and
only it) with `include_reason`, and an OSC 9 sequence that does not leave the
title/body separator dangling when there is nothing to add.
2026-10-24 01:53:49 +08:00
NCBM bf3a01854c core/cli: stop repeating the prompt in the notification body
Both channels give the title a field of its own, so a body carrying the same
line says it twice — a desktop notification showed "Allow 'rm -rf /'?" as its
title and again underneath. The body is now only the one-line reason, which is
what `include_reason` is for, and nothing when it is off.

OSC 9 joins title and body with a colon, so a body-less one is formatted without
the separator instead of leaving it dangling.
2026-10-24 01:53:38 +08:00
NCBM abd8c4d805 docs: describe the notification that stays and the answers it offers
The table of what a notification answers was out of date: the limit prompt and
the batch confirm now offer their own answers, and a skill write distinguishes
"once" from "this chat". The desktop section now says what actually keeps the
notification up (no expiry, urgency at the default) instead of naming `critical`,
and the follow-up rule — announced at once, past the grace period — is written
where a reader looks for why a prompt appeared without waiting.
2026-10-24 01:50:12 +08:00
NCBM 3631af2d60 test: cover what a prompt notification asks for and offers
The channel was the one piece the fakes never reached: they record the
`DesktopRequest`, not the call into `desktop-notifier`, so nothing pinned the
expiry the library is actually asked for. A stub module stands in for the extra,
which keeps the test runnable without it. Also covers the answers a prompt names
(`limit`, batch, skill write) reaching the notification and coming back, and the
prompt that follows such an answer being announced without a second grace period.
2026-10-24 01:50:12 +08:00
NCBM 3a54745d01 core/cli+prompting: let a prompt name the answers its notification offers
Every button-bearing prompt offered the same three answers, so the ones whose
terminal read takes more than yes or no lost theirs the moment the human
answered from the notification: the limit prompt could not say "stop asking this
turn" (`yyy`), the batch confirm could not say "one at a time" (`m`), a skill
write could not say "this chat" (`s`), and the limit prompt could not be
answered at all — its wait was not even consulted.

`PromptWait` now carries `choices` (a key the terminal read accepts, plus a
label a human reads), `prompt_wait` passes them through, and the notifier sends
them as buttons, appending "Go to terminal" as the way back. The limit prompt
reads both surfaces like a timed confirm does, and answers that used to be
terminal-only tokens arrive as themselves.

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:
the grace period and the burst throttle exist for prompts the human has not
noticed yet, and they have.
2026-10-24 01:48:22 +08:00
NCBM b7044bbba7 core/cli: keep a prompt notification on screen
A prompt notification went out with the library defaults: the notification
server's own expiry. A desktop server drops such a notification after a few
seconds, so the announcement of a blocked turn had usually slid into the
notification tray by the time the human looked back.

It now goes out without expiry (`timeout=0`, the freedesktop value for "until
dismissed"). Urgency is left alone: measured on KDE Plasma, `critical` keeps the
notification up just the same, but paints it as urgent and asks the platform's
Focus mode to let it through — decoration a prompt does not need. Dismissing is
already the prompt's own job once it is answered, so nothing lingers.
2026-10-24 01:48:04 +08:00
NCBM d4485aff7a release: plyngent 0.12.0 2026-10-23 10:53:08 +08:00
NCBM 0244a48998 docs: say how wrapper options are read
The destructive-confirm bullet described the old hand tables. It now names the
getopt spec per wrapper, and the rule that follows from it: an option the spec
does not know stops the unwrap and is reviewed, while the wrapper itself stays
the command the denylist judges.
2026-10-23 10:47:55 +08:00
NCBM c1b40a7bf4 test: cover options read with getopt
`--` ends a wrapper's options, jammed values (`-p2222`) and launcher flags
(`uv run --no-project pytest`) resolve, and an unknown option stops the unwrap
with the wrapper as the command — where the review asks and the denylist still
sees `sudo`.
2026-10-23 10:47:45 +08:00
NCBM 12236cffb5 core/tools: read each wrapper's options with getopt
Four hand-written tables said which wrapper options take a value, and the
comment above them admitted the failure mode: an entry for a flag eats the
program behind it, which is how `exec -c rm -rf /` hid `rm`. Guessing was the
other half — an option nobody listed was skipped bare, so the token after it
became the program.

Each wrapper now carries a `getopt` spec instead (`command_scan._WRAPPERS`:
short options with `:`, long options with `=`), which spells flags and values
apart, understands jammed forms (`-p2222`, `--with=pkg`), `--`, and unique long
prefixes, and stops at the first token that is not an option — exactly the
wrapped program. The remote clients and the `run` launchers are the same thing
with a flag on the spec.

An option the spec does not know is no longer skipped as if it were a flag: the
unwrap stops at that wrapper (`unparsed`) and the review asks — `options not
understood sudo (cannot read its options: option --new-sudo-flag not
recognized)`, with the whole argv. The wrapper itself stays the command the
denylist judges, so a new `sudo` flag cannot hide `rm` from the hard gate.
2026-10-23 10:47:36 +08:00
NCBM 3cb4d490ed core/tools: announce a remote shell instead of judging it
`ssh prod` runs no command this scan can resolve, and what is typed or piped
into the shell it opens never reaches any gate — `write_pty` and
`run_argv(stdin=…)` are both unchecked input. The tool description said nothing
about it, so the call looked like any other.

The hop is now reported even when no command follows
(`RemoteTarget.shell`), and the review announces it: `remote shell on 'prod'
(ssh with no command; its input is not checked)`. It is a soft confirm like the
other reviews (YOLO may skip it, an escalation behind a hop still may not) — the
client itself stays outside the denylist, since there is no program to name.
2026-10-23 10:43:05 +08:00
NCBM e1c857fd05 docs: record how far the command gate reaches
The destructive-confirm bullet says what the review resolves past, so it
now names remote clients: the far-end program is the one judged, `BatchMode=yes`
always asks, and an interactive remote shell — `ssh host` with no command, or
anything typed or piped into it — is outside the scan and stays a host decision.
2026-10-23 10:37:23 +08:00
NCBM 8c289db545 test: cover the far end of ssh
What the scan resolves (destination, options, jammed `-p2222`, mosh's `--`, a
hop behind `sudo`), what it leaves alone (a client with no command), and both
gates: `check_command_allowed` now refuses `ssh prod rm -rf /` and prompts for
the `sudo` behind the hop, while harmless remote commands stay allowed; the
review prints a remote `bash -c` code block and always asks under
`BatchMode=yes`.
2026-10-23 10:37:14 +08:00
NCBM 0cb740393e core/tools: judge an ssh command by what runs on the far end
The gate judged `ssh` itself: `unwrap_command` knew env wrappers and
`<launcher> run`, not a remote client, so `ssh prod rm -rf /` reached
`check_command_allowed` as "ssh" — not denylisted — and `classify_danger` found
neither an interpreter nor a detached run. The command ran unannounced.

`ssh` / `mosh` are wrappers now: their own options are skipped, the first token
that is not an option is the destination, and the program under review is the
one after it (after `--` for mosh). The hop is reported on the scan
(`RemoteTarget`), so `ssh prod bash -c …` is reviewed with the code it carries,
an escalation behind the hop keeps its confirm through YOLO, and the denylist
matches the remote program (`rm`, `sudo`, `systemctl reboot`).

`-o BatchMode=yes` cannot prompt at either end, so a command running with it is
always confirmed, whatever program it is.

A client with no command only opens a remote shell: nothing to judge at spawn
time, and what is typed or piped into that shell never reaches the scan.
2026-10-23 10:37:04 +08:00
NCBM d5c3f712ff docs: describe when a prompt listens for the answer
The page said a notification answer is treated like a typed one, without saying
that the prompt has to be listening before the notification exists — the grace
period is why. One paragraph says it, together with the price: a question that
offers a reply field reads in polling mode, so no Tab completion for that
prompt.
2026-10-23 10:25:40 +08:00
NCBM 16c5e01e44 test: cover answers from the notification
Three seams: the offer is known when the wait starts (a confirm and a question,
with and without the platform capability), a button pressed after the
notification appears answers the tool confirm it belongs to — a pty stands in
for the terminal so the read has to consult both surfaces — and `confirm()`
takes an answer passed in and one offered by the wait hook, without asking the
backend.
2026-10-23 10:24:56 +08:00
NCBM 80ad34f8fe core/cli+prompting: hear the notification while a prompt waits
Whether a prompt could be answered from its notification was decided by
`notify_desktop`, which only runs once the grace period is over — by then the
prompt had already committed to the plain terminal read. A pressed button or a
typed reply landed on a queue nobody read, and the human had to come back to the
terminal to answer the prompt the notification was showing.

A wait now asks up front whether an answer may arrive (`answer_capability`: the
kind, `actions`, the channel and the platform's capabilities) and listens on both
surfaces from its first poll. The desktop channel is resolved once, in the
background from `install_notifier`, so the capability probe never lands on a
prompt that is already waiting.

`confirm()` takes an optional `source` (the form's submit confirm and every
limits.py confirmation go through it), which also leaves one place — not two —
parsing `allow` / `deny` into a yes or no.
2026-10-23 10:24:46 +08:00
NCBM b2d6acae26 docs: record the Files API shapes
Uploading a picture and referencing it by id is the one image path the API
offers that we do not use, and the vendor shapes do not line up: the upload is
the same multipart `POST …/files` with a `file` part everywhere, but the object
that comes back is `object`/`bytes`/unix time on OpenAI, `type`/`size_bytes`/
RFC 3339 on Anthropic, and DeepSeek serves both dialects under two base URLs;
the reference block differs a third time per surface, and OpenAI's chat
completions cannot name an image by id at all.

Recorded next to the inline-path limits, with the three constraints that decide
any future wiring: an id belongs to the provider that minted it (like
`provider_blocks`), the OpenAI path implies the Responses convention, and the
token bill is unchanged — uploading only buys the inline size cap, a smaller
request body, and not re-sending the same bytes every round. The Anthropic
per-file limit is marked unverified: its API reference is unreachable from this
host's region.
2026-10-21 21:44:03 +08:00
NCBM 8926980a02 docs: describe the image gate
`image_in` is not just the mention switch any more: the table names where each
image-carrying path is gated, and says why the gate exists — a text-only model
answers `[Unsupported Image]`, and a gateway that cannot read image blocks feeds
the model raw base64, which is easy to mistake for a delivered picture.
2026-10-21 21:39:41 +08:00
NCBM 37e9395ac5 test: cover the image gate
The gate has four entry points and each now has a test: `view_image` and
`fetch_local` refuse with the message naming the config key, the registry drops
a plugin tool's picture and says why, and the workspace helpers keep `None` (no
host verdict) permissive. On the CLI side the verdict is asserted to follow the
selected model, and to reach the tools once an instance is bound.
2026-10-21 21:39:33 +08:00
NCBM da6f4b7dd7 core/tools+agent+cli: refuse a picture the model cannot take
`[providers.*.models.*].image_in` gated only the `@path` mention: `view_image`
attached whatever it was asked for, and a tool result's picture (an MCP server's
`image` content, a plugin's `ToolOutput`) went out the same way. On a model that
does not read pictures the provider answers `[Unsupported Image]` one round
later, and a gateway that cannot translate image blocks hands the model raw
base64 — the model then reports "I can only see a base64 payload", which reads
like the picture arrived when it did not.

The verdict now lives on `InstanceState.workspace` (`image_in` + the
`(provider, model)` it came from) and `tools.workspace.image_input_refusal`
turns it into the one message every path shares:

    error: model 'deepseek-v4-pro' does not accept image input
    (set image_in = true under [providers.deepseek.models."deepseek-v4-pro"])

`view_image` and `fetch_local` refuse with it before reading any bytes (the
fetch keeps its metadata line, so `body_kind=image` and the size stay visible);
the registry drops an image from a `ToolOutput` it did not vouch for and appends
the same reason to the text. That last one is the safety net for MCP and plugin
tools, which know nothing about the model.

`ReplState.sync_image_capability` publishes the verdict and runs from
`rebuild_client`, so `/provider` and `/model` flip the gate immediately, and
`_run_chat` seeds it before the first turn. A host that binds no instance
declares nothing and keeps sending pictures, as before.
2026-10-21 21:39:24 +08:00
39 changed files with 1758 additions and 370 deletions
+5 -5
View File
@@ -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/`)
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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`
+6 -4
View File
@@ -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]
Generated
+66 -66
View File
@@ -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]]
+2 -2
View File
@@ -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
View File
@@ -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",
] ]
+22 -1
View File
@@ -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
+1
View File
@@ -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.
+32 -2
View File
@@ -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),
+34 -19
View File
@@ -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)
source = _answer_source(wait)
if source is None:
token = backend.read_line(_CONTINUE_PROMPT, default="y").strip().lower() 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")
+112 -23
View File
@@ -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:
with self._resolve_lock:
if self._resolve_desktop: if self._resolve_desktop:
self._desktop = load_desktop_channel() self._desktop = load_desktop_channel()
self._resolve_desktop = False 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
+16
View File
@@ -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).
+2 -1
View File
@@ -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.
+47 -7
View File
@@ -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)
+2
View File
@@ -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
+312 -154
View File
@@ -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 == "--":
return index + 1
if _ASSIGNMENT.match(token):
index += 1 index += 1
continue parsed = _parse_options(parts, index, spec)
if token.startswith("-"): if parsed.unparsed:
return _Step(unparsed=parsed.unparsed)
for option, value in parsed.options:
if option in spec.command_opts:
command_strings.append(CommandString(wrapper=name, option=option, text=value))
index = _skip_prefixes(parts, parsed.index, spec)
if spec.remote:
return _remote_step(name, parts, index, parsed.options)
return _Step(index=index) if index < len(parts) else _Step()
def _spec_for(name: str) -> _Spec | None:
"""The option spec of a wrapper, or ``None`` when *name* wraps nothing."""
spec = _WRAPPERS.get(name) or _REMOTE_WRAPPERS.get(name)
if spec is None and name in _RUN_LAUNCHERS:
return _LAUNCHER_SPEC
return spec
def _skip_prefixes(parts: list[str], index: int, spec: _Spec) -> int:
"""Skip the ``FOO=bar`` assignments and the bare duration a wrapper takes first."""
while index < len(parts) and _ASSIGNMENT.match(parts[index]):
index += 1 index += 1
if token in value_opts: if spec.duration and index < len(parts) and _DURATION.fullmatch(parts[index]):
value = parts[index] if index < len(parts) else ""
_record_command_string(name, token, value, command_strings)
index += 1 index += 1
continue
# Jammed forms: ``--split-string=…`` and ``-S…`` carry the value in
# the token itself, so a command line may hide there too.
option, equal, attached = token.partition("=")
if equal:
_record_command_string(name, option, attached, command_strings)
elif token[:2] in _COMMAND_STRING_OPTS.get(name, frozenset()):
_record_command_string(name, token[:2], token[2:], command_strings)
continue
if leading_duration and not took_duration and _DURATION.fullmatch(token):
took_duration = True
index += 1
continue
return index
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
+14
View File
@@ -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:
+7 -2
View File
@@ -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: ')}"
+6 -1
View File
@@ -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}"
+185 -24
View File
@@ -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)))
+1 -1
View File
@@ -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:
+20 -8
View File
@@ -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:
+42
View File
@@ -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()
+34
View File
@@ -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()]
+19 -4
View File
@@ -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:
+129
View File
@@ -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
+175 -6
View File
@@ -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:
+7 -6
View File
@@ -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:
+22
View File
@@ -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
+19
View File
@@ -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.
+87 -1
View File
@@ -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")
+52
View File
@@ -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)"],
+19 -1
View File
@@ -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
+84 -6
View File
@@ -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 ""
+16
View File
@@ -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)
+50
View File
@@ -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
+6 -1
View File
@@ -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,