56 Commits

Author SHA1 Message Date
NCBM 4676b9af32 release: plyngent 0.14.2 2026-10-26 00:54:34 +08:00
NCBM 865d314234 docs: say the terminal keeps its own prompt line
A prompt answered from the notification reads both surfaces; the terminal still
shows the keys it takes, which a button cannot say.
2026-10-26 00:53:08 +08:00
NCBM 9139655e54 test: cover the limit prompt line while a notification offers buttons
The answer now comes from the notification hook, and the terminal still has to
spell out the keys it accepts.
2026-10-26 00:52:58 +08:00
NCBM 741d1a8526 core/cli: keep the limit prompt line on the terminal
Once the limit prompt offered its three answers as notification buttons, the
read took the button path and skipped the prompt line the terminal used to
print: the human at the terminal saw the reason and then an indefinite wait,
with nothing saying what to type. It writes the line again, as every other
prompt that reads both surfaces already does.
2026-10-26 00:52:48 +08:00
NCBM 44b6ab90ed test: cover the scratch hint in the escape refusal
One test for the write path that now names `new_temporary_workspace`, one
parametrized over read and exec, which must not.
2026-10-26 00:52:38 +08:00
NCBM fb77fd676b core/tools: point a refused write at a temporary workspace
A path outside the workspace is usually rejected while the model only wants a
scratch file: the refusal named the grant tool, so the model had to ask a human
for access it never needed. A write now also names `new_temporary_workspace`,
whose path needs no grant. Reads and commands keep the grant hint alone.
2026-10-26 00:52:28 +08:00
NCBM a79039f06f release: plyngent 0.14.1 2026-10-25 00:21:04 +08:00
NCBM ff656b800a test: cover a numbered read surviving a retry into a fresh attempt task 2026-10-25 00:19:14 +08:00
NCBM 2039d4f31f core: rebind the lineno tracker when retry continues a turn in a new task 2026-10-25 00:19:01 +08:00
NCBM 0e8d404642 release: plyngent 0.14.0 2026-10-24 04:43:15 +08:00
NCBM 19707c3bd6 docs: drop "Go to terminal" and explain the bell
The answer tables lose the third button (two answers, or the prompt's own),
the popup's close and a body click both withdraw it, and the desktop section
says why every popup rings the terminal: the human is at another window by
construction — the announcement waits out the grace period — and no desktop
lets a program raise one.
2026-10-24 04:41:01 +08:00
NCBM ae20f5131f test: cover the bell at announcement and the two-button answer set
A confirmation offers Allow once / Deny and a prompt's own ``choices`` are
the whole button set; a popup that goes out rings the bell once, and
withdrawing it afterwards — by click or close — adds nothing and never
answers the prompt. A reply field's text is an answer whatever it spells.
2026-10-24 04:40:50 +08:00
NCBM a7abc50aa5 core/cli: drop the go-to-terminal button and ring the bell when a popup goes out
"Go to terminal" promised something no desktop can do — raise another
application's window — and the popup's own close button does as much, so it
was a third button that answered nothing. `REVIEW` leaves the answer
vocabulary; a click on the body (the platform's default action) or a close
still withdraws the notification and leaves the prompt waiting.

The bell now rings as the notification is shown. A prompt is only announced
after `grace_seconds` of nobody answering it, so the human is at some other
window — possibly another virtual desktop, where a popup is easy to miss — and
the terminal's bell is the only portable cue that says where to look. It rings
once per announcement; withdrawing the popup afterwards stays silent.
2026-10-24 04:40:39 +08:00
NCBM 7452224793 docs: name the workspace line, the popup deadline and the bell
The body table gains the workspace (last line, `~` for home, following a
rebind), the desktop section says which popups expire and why, and "Go to
terminal" is described as what it does: withdraw, ring, leave the prompt
waiting — no window is raised, because no desktop allows it.
2026-10-24 04:35:26 +08:00
NCBM 474bf57093 test: cover the workspace line, the popup deadline and the bell
That a home path under `~` names the chat and follows a rebind, that a
timed prompt hands its deadline to the popup while an untimed one keeps it,
and that going to the terminal — by button or by clicking the body —
withdraws the notification, rings the bell and leaves the prompt waiting.
2026-10-24 04:35:15 +08:00
NCBM ea0827ddfb core/cli: name the workspace, let the popup expire, ring the bell
A notification said which prompt was waiting but not which chat, and two
windows look alike. The body's last line is now the workspace the chat runs
in, with the home directory shown as `~`, and it follows a session resumed
into — or rebound to — another directory: `ReplState` retargets the installed
notifier instead of freezing the directory the process started in.

A timed prompt has a deadline, the popup did not, so it could stay on screen
announcing an answer window that had already closed. The deadline is now the
popup's own timeout (rounded up to whole seconds); a prompt without one keeps
the old "until dismissed" behaviour.

"Go to terminal" (and a click on the body) withdrew the notification and left
the prompt waiting, but nothing showed the human *where*. No desktop lets a
program raise another window — Wayland has no such call and the X11 ones need
a helper per WM — so the terminal rings its bell instead.
2026-10-24 04:35:04 +08:00
NCBM 39258b2670 docs: say what a notification now carries and when silence answers
The body is no longer "the reason or nothing": it is the question, the
reason behind `include_reason`, the object of the prompt and the deadline,
with the desktop keeping the lines apart and the terminal flattening them.
The tables gain the answers a question offers (a reply field and the way
back), the channels' click-to-terminal behaviour, and the secret prompt that
is announced nowhere at all.
2026-10-24 04:22:42 +08:00
NCBM f511c08afb test: cover the body a prompt notification carries
What a prompt declares (question, object, deadline), how the two channels
render it (lines apart on the desktop, one line in a terminal), that a long
reason goes before the deadline does, that a question keeps its reply field
*and* the way back to the terminal, that a body click leaves the prompt
waiting, that a reply spelling "review" is still an answer, and that a secret
is never announced.
2026-10-24 04:22:00 +08:00
NCBM 04d34b34f3 core/cli: name what a confirm is about, and when it denies itself
A tool confirm's notification said which tool asked and nothing else, and
the three timed prompts (denylist, directory access, skill write) hid the
clock: each of them denies on timeout, so a human reading the notification
late was answering a prompt that had already given up.

`_tool_facts` lifts the object out of the call — the program's name, or the
file's — and never an argument, a URL or a piece of code: those stay in the
reason, which only travels with `include_reason`. The timed prompts pass
their timeout as `expires_in`, and a batch names the tools it asks about the
same way instead of keeping them behind the reason.
2026-10-24 04:21:37 +08:00
NCBM 9ed4620579 core/cli: render a prompt's body from what it declares
The body was the reason line or nothing, so with the default
`include_reason = false` every notification was a bare title. It is now the
question, then the reason when the config allows it, then what the prompt is
about, and last the deadline — filled in that order until the budget is gone,
so a long reason drops out before "no answer in 30s = deny" does. A desktop
popup wraps and keeps the lines apart; a terminal escape sequence carries them
on one line.

Two answers were missing from the surface: a reply prompt had no button at all
(a question could not be sent back to the terminal), and the notification body
was not clickable. Clicking it is the freedesktop default action, so it now
withdraws the notification and leaves the prompt waiting — the same as the
"Go to terminal" button. A typed reply is recorded by `reply()`, which keeps a
reply that spells "review" an answer; a secret is never announced at all.
2026-10-24 04:21:25 +08:00
NCBM 5f7a70bf84 core/prompting: let a prompt say what it asks and when silence answers
`PromptWait` only had a short title and the reason line behind
`include_reason`, so a host that keeps the reason out had a bare title to
show — and a question, whose text *is* what is asked, was cut to the title's
few words. It now carries `body` (the question itself), `facts` (the object:
a program or a file name, never an argument) and `expires_in` (when the
default answer takes over). `ask` and `choose` put their question in the body
and a choice names its options; a secret says in its docstring why its prompt
is announced but never answered.
2026-10-24 04:21:14 +08:00
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
41 changed files with 2536 additions and 446 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.
- **`responses_bridge` / `responses_dispatch`**: chat ↔ OpenAI Responses; stream text/reasoning deltas (`response.reasoning_text.done` repeats what its fragments spelled out, so the loop streams each part once and still keeps the whole text in the round's message); tool calls + usage on `response.completed`; a request carrying `tools` returns every round's reasoning as a `reasoning` item of `reasoning_text` parts — "必须完整回传 `reasoning_content` … 即使该轮模型未实际进行工具调用" (a missing item and an empty part are both a 400, so a round that kept none sends a blank line) — and a round's `function_call` items stay contiguous ahead of their outputs (`call, output, call, output` was a 400: the second call reads as a round of its own without reasoning); reasoning is read back from the item's `content` parts.
- **`messages_bridge` / `messages_dispatch`**: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments; `thinking` from the request's `reasoning_effort` (level → token budget, floor 1024, `max_tokens` grown when the budget would leave no room) plus the client's configured `thinking_budget_tokens`; a thinking block the model refuses (from Claude 4.7 on, `thinking.type.enabled` is a 400 asking for adaptive thinking + `output_config.effort`; a model whose thinking is always on refuses `thinking.type.disabled`) gets one retry with the shape it takes — adaptive thinking with the level as the effort, or no block at all — and the refusal is remembered per model on the client, so only its first request pays the round trip (only a refusal that arrives before the stream yields anything is retried; a later failure has already been seen); a server tool (`server_tool_use` + its `web_search_tool_result`) is the surface's own business — it streams `input_json_delta` fragments like a call but nothing local asked for it, so only a `tool_use` block becomes a tool call and nothing is executed; `thinking` and the two search blocks are kept as `AssistantChatMessage.provider_blocks` (the thinking text also lands in `reasoning_content`, so it streams as reasoning) and go back with the round, which is also why a round that kept only those blocks is not an empty completion; a chat-completions request drops the field (`compat.coerce_chat_messages`).
- **Images** (see `doc/images.md`): a user turn's `content` may be a part array (`ChatTextPart` + `ChatImagePart`), and a tool result carries its pictures in `ToolChatMessage.images`; each surface lifts them into what it can express — chat-completions content blocks (tool-result images move into the user turn after the tool run, since a `tool` message holds text only), Anthropic `image` blocks (inside `tool_result` for a tool result), Responses `input_image` parts (in a `function_call_output` item). Pictures belong to user turns: an image in a system or assistant message is a provider 400. `agent/images.py` sniffs the format from the bytes and caps an inline picture at 8 MiB, because every round re-sends it; `[providers.*.models.*].image_in` is the opt-in.
- **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.
- **`@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`.
@@ -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'`).
- **`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=...`).
- **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_batch` (serial steps, `pipe_out` on provider, `mix_stderr`, `stop_on_error`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (escapes) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`\xHH`, `ctrl+x`, `key=esc`); secrets via `ask_into_pty` (not `write_pty` data); session limit/idle TTL/output budget; close terminate→kill.
- **`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 (`<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.
- 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.
- **`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`).
@@ -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`).
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, *, body, facts, expires_in)`: the short `title`, the `body` it asks (a question, whole), the gated `detail` (a reason that can name a command or a URL), `facts` (the object — a program or file name, never an argument), and the `expires_in` after which its default answer takes over. It 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, 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 — a prompt with a deadline gives it that deadline instead — and every failure falls through to the next channel instead of raising. A notification that went to the desktop rings the terminal's bell: a popup may be on another virtual desktop, and no desktop lets a program raise another window. The body is filled in reading order (question, reason, object) until the budget is gone, with the deadline and the workspace — the directory the chat runs in, `~` for home — kept for last, so a long reason drops out before they do; the desktop keeps the lines apart and the terminal flattens them. A popup is sent with no expiry, except for a prompt that gives up on its own: its deadline becomes the popup's timeout. There is no "go to terminal" button — no desktop lets a program raise another window, and the popup's own close does as much; instead every notification that went to the desktop also rings the terminal's bell, and a click on the body (the platform's default action) or a close just withdraws it, leaving the prompt waiting. A `secret` prompt is never announced.
### CLI (`cli/`)
+3 -3
View File
@@ -178,7 +178,7 @@ max_context_tokens = 200000
# [notify]
# grace_seconds = 5.0 # announce only after this long
# channels = ["auto"] # auto | desktop | terminal
# actions = true # allow / deny / review buttons + reply field
# actions = true # allow / deny buttons + a reply field
# include_reason = false # off: the reason may name a command or URL
# Deletes: `delete_path` moves the path to the system trash. See doc/trash.md.
@@ -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).
**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**; 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 announcement says what is asked: a question travels whole in the body, a confirmation names the program or the file it is about (`program: rm`, `target: todo.md` — never the arguments), a prompt that denies itself on timeout says so (`no answer in 30s = deny`) and hands that deadline to the popup, which then disappears with it, and the last line is the workspace the chat runs in (`~/Projects/plyngent`). It also rings the terminal's bell, since a popup can sit on another virtual desktop and no desktop lets a program raise another window; closing the popup or clicking its body withdraws it and leaves the prompt waiting. A secret is never announced. 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).
@@ -357,7 +357,7 @@ Safety defaults:
- 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
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).
- 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
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
The 2026-10-17 docs plus a live probe (`deepseek-flash`):
@@ -93,16 +127,32 @@ completed in its quoted form).
## Opting in
`[providers.<name>.models."<model>"].image_in` is the gate — an `@image` mention
is skipped, with a note naming the key, on a model that does not set it, and the
default catalogs mark what takes pictures (`deepseek-flash` yes,
`[providers.<name>.models."<model>"].image_in` is the gate, and it covers every
way a picture can enter a turn — not only an `@image` mention:
| 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
has to be declared: the flag is an explicit opt-in.
## Not implemented
- **Files API** (`POST /files` → `file_id`): it lifts the 32 MiB per-image cap
and avoids re-uploading the same bytes, but it does not change the token bill
(the model still processes the picture in every request that carries it), so a
local 8 MiB cap plus resizing covers the common case.
- **Files API**: see above — no image path uses it today; a local 8 MiB cap plus
resizing covers the common case, and the upload changes no token cost.
- **Image output**: the API documents none.
+74 -12
View File
@@ -5,10 +5,25 @@ window, the wait is invisible until you come back — so the CLI announces it.
A prompt is announced only after `grace_seconds` of waiting (5s by default), so
answering at the terminal stays silent. Bursts are throttled by
`min_interval_seconds`, and the announcement says what is being asked, not what
it would run: the one-line reason can name a command, argv or URL, and a
notification outlives the prompt (and may be mirrored to a lock screen), so it
travels only with `include_reason = true`.
`min_interval_seconds`.
A notification says what is asked, not what would be run. The title is a short
label; the body carries, in this order:
| Line | When |
|---|---|
| the question itself | an `ask_user_line` question, an `ask_user_choice` prompt |
| the reason line | only with `include_reason = true`: it can name a command, argv or URL, and a notification outlives the prompt (and may be mirrored to a lock screen) |
| the object | a confirmation or a batch: the program's name (`program: rm`), the file's (`target: todo.md`), the tools of a batch, `kind: directory` for an access request, the skill directory — never an argument, a URL or a piece of code |
| `no answer in 30s = deny` | a prompt that takes a default answer on timeout (command denylist, directory access, skill write) |
| the workspace | always: the directory the chat runs in, with the home directory shown as `~` (`~/Projects/plyngent`), so several waiting chats are told apart — it follows a session resumed in another workspace |
The lines are filled in that order until the body is full and each one is
capped, so a long reason drops out before the deadline and the workspace do.
The deadline is the one line a human must not miss, because not answering *is*
an answer there; the workspace comes last and is kept for the same reason. A
desktop popup wraps and keeps the lines apart; the terminal channel carries the
same body on a single line.
## Configuration
@@ -32,18 +47,52 @@ a prompt answered inside the grace period is never announced at all.
| Prompt | Notification offers |
|---|---|
| tool confirm (single / a round's batch) | Allow once / Deny / Go to terminal |
| command denylist, `fetch_local` private host | same three |
| directory-access request, skill write | same three |
| tool confirm (single) | Allow once / Deny |
| a round's batch of confirms | Allow all / Review one by one / Deny |
| command denylist, `fetch_local` private host | Allow once / Deny |
| directory-access request | same two (the level asked for, not a lower one) |
| skill write | Allow once / This chat / Deny |
| limit-continue | Continue / Continue, stop asking / Stop here |
| `ask_user_line` | a reply field — the answer *is* the text you type |
| `ask_user_choice`, `ask_user_form`, PTY secrets | nothing (terminal only) |
| limit-continue | nothing (terminal only) |
| `ask_user_choice`, `ask_user_form` | nothing (terminal only): the question and its options are in the body |
| PTY secrets | nothing at all: a secret prompt is never announced |
Each prompt says which answers it accepts (`prompt_wait(..., choices=…)`), and
those are the buttons; one that names none gets Allow once / Deny. There is no
"go to terminal" button: it could not do what it says — no desktop lets a
program raise another application's window (Wayland has no such call, the X11
ones need a helper per window manager) — and the popup's own close button does
as much. Clicking the body (the platform's default action) or closing the popup
withdraws the notification and leaves the prompt waiting; the prompt is not
announced a second time.
What says *where* to look is the bell: every notification that went to the
desktop also rings the terminal's bell. The announcement only happens when
nobody answered inside the grace period, so the human is at some other window —
possibly another virtual desktop, where a popup is easy to miss — and the bell
is the only portable cue. It is the terminal's own bell, so a terminal with its
bell switched off stays silent.
An answer read from the notification is treated exactly like a typed one —
`allow` is the `y` the prompt accepts, `deny` is `n` — and it never records a
session grant: the button means "this once". "Go to terminal" withdraws the
notification and leaves the prompt waiting. A secret is never asked for in a
notification, and never echoed into one.
session grant: the button means "this once", which is why a prompt with an
extra answer (this chat, stop asking) offers it as a button of its own. Text
typed into a reply field is the answer, whatever it spells. A secret prompt is
announced nowhere and echoable from nothing: its value would have to travel
back through a surface that outlives it.
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. The terminal keeps its own prompt line with the keys it accepts —
a button elsewhere does not say what typing here would do. 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
@@ -64,6 +113,19 @@ Notifications are shown from the library's async API on a loop that runs in a
daemon thread, because callbacks only fire while the loop that owns the
notification is running.
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. A prompt that gives up on its own is the
exception: the popup is sent with its deadline as its timeout (30 seconds for a
prompt that denies after 30s), so the notification disappears when the answer
window closes instead of announcing a prompt that is already gone. 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
No dependency and no extra: the CLI writes an escape sequence to `/dev/tty`
+18 -5
View File
@@ -45,15 +45,28 @@
# Notifications while a prompt waits on the human: a tool confirmation, a
# directory-access grant, a skill write, an ``ask_user_*`` question, or the
# limit-continue prompt. A prompt is announced only after `grace_seconds` of
# waiting, so one you answer at the terminal stays silent; `min_interval_seconds`
# throttles bursts.
# waiting, so one you answer at the terminal stays silent — except a prompt that
# 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:
# `pip install plyngent[notify]`) and falls back to a terminal escape sequence;
# "desktop" / "terminal" pin one of them.
# `actions` offers allow / deny / review as notification buttons; where the
# platform has none the notification stays text-only.
# `actions` offers the answers the prompt accepts as notification buttons (allow /
# deny when it names none); where the platform has none the notification stays
# text-only. Every notification that went to the desktop also rings the terminal's
# bell — the popup may be on another virtual desktop, and no desktop lets a program
# raise another application's window. Closing the popup (or clicking its body)
# withdraws it and leaves the prompt waiting.
# The body says what is asked: a question or a choice travels whole (the title is
# only a short label), a confirmation names the program or file it is about, and a
# timed prompt says when silence answers ("no answer in 30s = deny", which is a
# deny for the denylist, directory-access and skill-write prompts). The last line
# is the workspace the chat runs in (`~` for home), so several waiting chats are
# told apart; a prompt that has a deadline hands it to the popup, which then
# disappears with it.
# `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). A
# secret is never announced.
# [notify]
# enabled = true
# grace_seconds = 5.0
Generated
+66 -66
View File
@@ -5,7 +5,7 @@
groups = ["default", "dev", "notify"]
strategy = ["inherit_metadata"]
lock_version = "4.5.1"
content_hash = "sha256:6b62075d8b95d83a7d5d6c99c073db457750af4c8a4e988adbef8799f6f4167e"
content_hash = "sha256:baade01b6537caca957e9fe9ee94e82f998e39255b52ed94db94b501e620398b"
[[metadata.targets]]
requires_python = "~=3.14"
@@ -659,29 +659,29 @@ files = [
[[package]]
name = "ruff"
version = "0.16.10"
version = "0.17.2"
requires_python = ">=3.7"
summary = "An extremely fast Python linter and code formatter, written in Rust."
groups = ["dev"]
files = [
{file = "ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511"},
{file = "ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d"},
{file = "ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550"},
{file = "ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d"},
{file = "ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221"},
{file = "ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a"},
{file = "ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8"},
{file = "ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e"},
{file = "ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a"},
{file = "ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3"},
{file = "ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3"},
{file = "ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03"},
{file = "ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be"},
{file = "ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d"},
{file = "ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a"},
{file = "ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca"},
{file = "ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b"},
{file = "ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017"},
{file = "ruff-0.17.2-py3-none-linux_armv6l.whl", hash = "sha256:96a65e755d8dba094707fb23ab602111d9a31ec2835b3a6992a1914a8fcb211e"},
{file = "ruff-0.17.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:39c5b55c960515cdb32be16e69efb5734c589c06a0eb38a22694524a336b889d"},
{file = "ruff-0.17.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:92a80d2f44584dabb0189b3a6c393e37e525e04942838482010575cb81945be7"},
{file = "ruff-0.17.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7d0906677d89bbf3cf73cde64b5bbe2ab35df3ae43e49ee2627301e91265bac"},
{file = "ruff-0.17.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cfd783590edf2b77317882631900f6a13ea2e8eacefe6ebf2370d2715299160"},
{file = "ruff-0.17.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122258f6ecde0e0dd1bb60b24263c3edde4338caf723fca711cc729f9bfd414a"},
{file = "ruff-0.17.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f8fba429d6ac4128ab29cfb8fa6240d2fa671d04adf7f84d3b22fc7b6b6bea4"},
{file = "ruff-0.17.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:583bd67bb9e7061dd09bc46a0a884bae2bbd214dbcd48e509813612619683869"},
{file = "ruff-0.17.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c60f93fec7142dba5b8f487f94bf2f6231dae24dc11e4c48648347c8b0720004"},
{file = "ruff-0.17.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c555a877f8f41447e318101cfefea9bf12dfa9f82eedf90c376e57e069f917d0"},
{file = "ruff-0.17.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ba405cbd5ec7bda1f555d5e52be1fd68d02608d4d95d24591bdac92cfacb579f"},
{file = "ruff-0.17.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8c21b4ab24485358f17291a4f8dc5399b834e963b8efedd920fe4fdb02081bb4"},
{file = "ruff-0.17.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5875a16b0de70b43cb81eabea5ae5e8f5d1b2fd1b5e6ed07f80cfe5019d0bd5a"},
{file = "ruff-0.17.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d086be452eed686aa51271ecf2cbe5037956517cc6ee39a17b4489ac12f8bbe"},
{file = "ruff-0.17.2-py3-none-win32.whl", hash = "sha256:6bce7d1f5165701ae18f5df85c65afe78cddfa6c5fb4b9bdb0014d60388f3edd"},
{file = "ruff-0.17.2-py3-none-win_amd64.whl", hash = "sha256:1b59d447f197bef9ef48a22ba1a6016137753c6099e9bac9c36eb566e422424c"},
{file = "ruff-0.17.2-py3-none-win_arm64.whl", hash = "sha256:195ad918027f817c119310ca89a42d347887dfc28934aaa869325e9c5dc9d962"},
{file = "ruff-0.17.2.tar.gz", hash = "sha256:84d632402d14c23b488f941db23712d6736a013225162062d34558c3f3c780eb"},
]
[[package]]
@@ -697,7 +697,7 @@ files = [
[[package]]
name = "sqlalchemy"
version = "2.1.5"
version = "2.1.6"
requires_python = ">=3.11"
summary = "Database Abstraction Library"
groups = ["default"]
@@ -705,48 +705,48 @@ dependencies = [
"typing-extensions>=4.6.0",
]
files = [
{file = "sqlalchemy-2.1.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5541a110ac4bb6377c785aea1b8f0b4d6658ed0ca5061ed1115d0725062b026b"},
{file = "sqlalchemy-2.1.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f029b2f7114623c03f762596f313515a2358004c44eb18924a15940f2e27d30f"},
{file = "sqlalchemy-2.1.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc44dcc80e89a006af415edffc7409b1f2976a4597065449c166987f95801ab4"},
{file = "sqlalchemy-2.1.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:72ddd7a8ec187d02de0ef316a15aaf381ddcc594958247b02a3dcdb4c42f0b02"},
{file = "sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f5173a6e60a15090f9e2348a8ca94cc13de72796c16733f3b62fbb22acba07b8"},
{file = "sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:09b10bd68223d57cce39c08947efae3f91ac40b552e07c7c869c3350a0fc4c23"},
{file = "sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ea4cb5022462d9f15e7b72a52c267385aa97ac224c8503ac63ba90c0067ad0d9"},
{file = "sqlalchemy-2.1.5-cp314-cp314-win32.whl", hash = "sha256:c3ffbe6c680c626403b7e9697d92465501078731b9e8863b1410826e66b950e1"},
{file = "sqlalchemy-2.1.5-cp314-cp314-win_amd64.whl", hash = "sha256:061d089901319463a1160e4596191e355d2a9b4cce4852dac5adc301b085ac2f"},
{file = "sqlalchemy-2.1.5-cp314-cp314-win_arm64.whl", hash = "sha256:f5bc7fb93f1c17efefeb062bc4d2742ad1b71d56c36e6e2898a371ac6a56839a"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9bb993b1d0890a2da90589684c3d09de75df82c06aa8f7eda246c7cd0ea3e820"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df7e71ed051e4003e73484ef6547aacfb3a2322aac0c230d530c97e48e77b4d7"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c76d4cb9c76a6906563133449a76d9eae6c8529ca09484b8615ca604442b442e"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59a56be2e4e972061f9d5c1d5b01763b8b2207b48b956fbb6879c7443f5817f7"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc1a61d7f42e71746b5a2e10e90a6b174b4add441b7b6cf14278f1e029b2b800"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:740e96dfb99a615c8056070d3747efe8bce4c145e5953ccc9e4b004c139de0d2"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:92a0bb0595331c8e40bc615a67cc7bca9b761487409d1bf46c598b6ba5c0754d"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-win32.whl", hash = "sha256:11185c72b5372369166cb721537943f8e1e6e42a399e9bac19e02f3ee20592a4"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5173207b8fe1ddfc1d451c420a2a9cbbd0734b4a19896b8080e3744497e1c20c"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-win_arm64.whl", hash = "sha256:def2f117a6ec0a25202ee36a148bbcfad3d43e2f2bb2e698e6d4448c6db5cb19"},
{file = "sqlalchemy-2.1.5-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:a0b80f91d9ed92be7a134278ea2e83a693c07d9986824ca51d859af9e57e18f5"},
{file = "sqlalchemy-2.1.5-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3033ddeeeb34c6c536622c07e932a20f8c9f58d6dd76e56595a76b55a25011ff"},
{file = "sqlalchemy-2.1.5-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4d30723171d511ec16ac057872a38b9b395420c0aaf81b2f94dd7bf1345c5fe"},
{file = "sqlalchemy-2.1.5-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cefa5badc13df1c9082bacdfa435c8eb6dcb9b32717f51d09d1e160a599646f"},
{file = "sqlalchemy-2.1.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2549e503185897de1fc5ca7730d5daed0d27d0b26df4a059dcdd33ebe81b7b4b"},
{file = "sqlalchemy-2.1.5-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:5a6871ee159a943d874012de835d56d0544ee871a99dbae05bc053da315b2c23"},
{file = "sqlalchemy-2.1.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:51e290080415325b66a94d20f6360e310606acb832f2bd677d4fcb95728e7ce1"},
{file = "sqlalchemy-2.1.5-cp315-cp315-win32.whl", hash = "sha256:c228084025a28aa038aef9b821d842508650fe12fe51f06e9cdaa4393be7812c"},
{file = "sqlalchemy-2.1.5-cp315-cp315-win_amd64.whl", hash = "sha256:bc09dd22c3fc491a2df7d16ca4167de35573f40c2e82dd4d6ddae589a73e7032"},
{file = "sqlalchemy-2.1.5-cp315-cp315-win_arm64.whl", hash = "sha256:375ef1bf78bc269a220963163d2580d5377898ed23a00bdd98a02d755421573d"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:7af1c92255cf8d50e87a808ce41096907332acdd09f285426a52f7c300e152db"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85891e0828e76a77fe912995dbc3ee949099488ba0a2ab323535d2e767446b20"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a72e062d2d38826dd89231df692fb40f12d7d1b9d118be23bcc1cf93c6e31782"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b79ee943e2e12246f873345762b83dbadd1fcfc3e832eab83272d672704dbdea"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:e9418d87b55c469c2f1328246d4533238cdc43134c695d6b9a6b7a87da49d463"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:898d86c22640fe52ffe02b4abc043a3c6754b4875837599e739be440966dffa1"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b3ad20cd035392f2b91a6466e24643990c1378beda80c9bda87a3a41e841e838"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-win32.whl", hash = "sha256:db34eefaf992c84460ab26ecf2fc2bdcc68965d6becfae8182cae0efeaa3e8f8"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-win_amd64.whl", hash = "sha256:173054351d863ddada188284e309e87a9f030132c8d534e05458971ff061aa1e"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-win_arm64.whl", hash = "sha256:22c8d0873168a54f614e381559ea83d547b2cc5488b95b049bdc92667586bd5f"},
{file = "sqlalchemy-2.1.5-py3-none-any.whl", hash = "sha256:2f77b77b716f005b2dc3cc059d56df6a919bfb5b439e4149890a9df6c36be9e3"},
{file = "sqlalchemy-2.1.5.tar.gz", hash = "sha256:0df76cf1ca680ad745bd378a6299a058611a8a784eeaeb4ed78ad4a0b7ce2003"},
{file = "sqlalchemy-2.1.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5f9400b1234555b9ae78450123dec7aaf045d8d978a1e94967437ebe561ae120"},
{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.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39ca3a128260fb3aa1c33554f1993bc5ddf3f82375697028c49e7b411ab80b84"},
{file = "sqlalchemy-2.1.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8c34c200a124a1a5769671613b3fc05a21d0a50f7ad942cf0ee12d4390a112c"},
{file = "sqlalchemy-2.1.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:17bc5f508f5e5cee2722a5a14af0a48768b40e1fc5d1bfc92abf6ac587636bd7"},
{file = "sqlalchemy-2.1.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c757e13b63a25d59a2566c9b2e77adf14d969207a375ca82a9b4fa1a04b37845"},
{file = "sqlalchemy-2.1.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:73aa8d2f911a54d1310e8258a752207e2d7624f4db46e2320d734fe7218320a1"},
{file = "sqlalchemy-2.1.6-cp314-cp314-win32.whl", hash = "sha256:1927eabf26265ed962627e7a64626d9058da29cc0faea341091302ec108ce305"},
{file = "sqlalchemy-2.1.6-cp314-cp314-win_amd64.whl", hash = "sha256:fd1154c6394892e727ff234273f5eb2b4319e5714917b016602435d77add8dcb"},
{file = "sqlalchemy-2.1.6-cp314-cp314-win_arm64.whl", hash = "sha256:10a4fe106adbb46c9452f750d8a5d4013d9a7e9752f63631c3b255c062b748d0"},
{file = "sqlalchemy-2.1.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea5f3dd7450669acbfee7813d3a2a13876806b1d9e4c1626a7bf587c33c68472"},
{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.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da21b36d60f47dd04042e9372ffb3d1ededf5c60a76d43f8c3592221fb2d41cd"},
{file = "sqlalchemy-2.1.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:048d1beb9bfd7ca1afbcfdb8da9519712827df6cccc80a225715ce387d37bb3b"},
{file = "sqlalchemy-2.1.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8de444335e3723959913f6635d579276a3517b8c19cbdfaefb04e73b5480e88b"},
{file = "sqlalchemy-2.1.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:231d0fd85866f847c34c915116c386c83e677ab6f79a94c835b7ae7313b86095"},
{file = "sqlalchemy-2.1.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bf937348d4052eac6cfa93c809ad214be75b1f057e3fc0dd2e374a814a23a16b"},
{file = "sqlalchemy-2.1.6-cp314-cp314t-win32.whl", hash = "sha256:bc8b805fb04472e0f9cb3bc6aad90f24b202afc274b7b8bbd48042130e82997a"},
{file = "sqlalchemy-2.1.6-cp314-cp314t-win_amd64.whl", hash = "sha256:84033cab9fec2a515a62d76a7b4acafa83339917db9ea911b65b017928ae7dd8"},
{file = "sqlalchemy-2.1.6-cp314-cp314t-win_arm64.whl", hash = "sha256:96ace18758136905f8d5588ec35d6ae4fdc29ce4e4902518263ca33abf10de04"},
{file = "sqlalchemy-2.1.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:89d05a1ef531a1fee86d68e731c428b773bd37aabf743bf63dc3709c86f7254d"},
{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.6-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6e1a9ae71366164800942c477e663b31f3accef55d2e039cb7919fdb54b0086"},
{file = "sqlalchemy-2.1.6-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9bd573f8da863781ace89950d57a959397d5048b1cf69931c29b2b4ce1405bd7"},
{file = "sqlalchemy-2.1.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0d0424a4189f95886c78b48bb500a1dd7ae6dbae76deb552b15813dc298d9c8f"},
{file = "sqlalchemy-2.1.6-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:cc60fffea328f38187a4fce43f99c6bbf930de2b87b78251f43f2ba64a1af622"},
{file = "sqlalchemy-2.1.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c390f76f2fe408aa1a3121f3ca3d5c15698e14d913197417355fe3dc016f787d"},
{file = "sqlalchemy-2.1.6-cp315-cp315-win32.whl", hash = "sha256:79c1c20dffd4d8d7e669f096c88f261da8ba3646939cd3eb6faeccb92bae99c3"},
{file = "sqlalchemy-2.1.6-cp315-cp315-win_amd64.whl", hash = "sha256:f1e78019147ff7e08afaffb88fc3a0aa795171b147eacfc7ff873ce7a0d37dff"},
{file = "sqlalchemy-2.1.6-cp315-cp315-win_arm64.whl", hash = "sha256:3974e33fc322b645eedeac8a3dea93022466fee41a6385c744a6656aba293cd4"},
{file = "sqlalchemy-2.1.6-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e269c68ec200538ae3fa7f9e8d8ed2b4b34d974c25945286ccc2a488438acf32"},
{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.6-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:641a3742dac4b8f325667b585097de0f1d20eb3498506c92d5f04c20b3a6c823"},
{file = "sqlalchemy-2.1.6-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d96186af967d7ae3f76d1e9a8fb70b5beb0f0dbdacfd3b418a946fffbb8781ff"},
{file = "sqlalchemy-2.1.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:5a9b4cbc5f5cda3c9429e1ac503a57f3b95c59886f26af6ffb44e2e7628b7df8"},
{file = "sqlalchemy-2.1.6-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b7ddb305126d34fda64c3fe4632a43fb9b724da6cc7d1dedd8434e6c783b285b"},
{file = "sqlalchemy-2.1.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:70b435f719d7156623f49ac6539f30dd8471500b5acf399f3b318c1c17f6fa7f"},
{file = "sqlalchemy-2.1.6-cp315-cp315t-win32.whl", hash = "sha256:654192c9f192459ee5f63e6606cc6dd8c50681c28e0689d2a494fc9522b357c1"},
{file = "sqlalchemy-2.1.6-cp315-cp315t-win_amd64.whl", hash = "sha256:a053a9d9c9ccc9b72d90ecdcbabcbe57446defc7e8c52fc9978188f78742ac19"},
{file = "sqlalchemy-2.1.6-cp315-cp315t-win_arm64.whl", hash = "sha256:61dc3fa488208a2f03e0e25ad32950dc024545291acc7348f2e550ea1bc5abb4"},
{file = "sqlalchemy-2.1.6-py3-none-any.whl", hash = "sha256:106878cf3795cd3d357e2e858609f9e44f20f19b2d8e48a3532bef3f90b03fbe"},
{file = "sqlalchemy-2.1.6.tar.gz", hash = "sha256:499eae5c9051efe96cb9a73ec2fbde8ffa4186f102b9094ebab8fcc9d3b99f52"},
]
[[package]]
@@ -789,14 +789,14 @@ files = [
[[package]]
name = "wassima"
version = "2.1.4"
version = "2.1.5"
requires_python = ">=3.7"
summary = "Access your OS root certificates with utmost ease"
groups = ["default"]
marker = "sys_platform != \"emscripten\""
files = [
{file = "wassima-2.1.4-py3-none-any.whl", hash = "sha256:26022272d4618aada0e26df2e79dbc0e034be8be9d6d5396512690ffc6a94e7b"},
{file = "wassima-2.1.4.tar.gz", hash = "sha256:21bb77253bb8032c05393172c4d2df395f423b7e704538f3b2407f50c032034d"},
{file = "wassima-2.1.5-py3-none-any.whl", hash = "sha256:53d9a56cf55fc969f8cdd1993f8dc69f857d1c32229fb9084dc2b267044b6dde"},
{file = "wassima-2.1.5.tar.gz", hash = "sha256:b02c1985e527c9539396cc85b909857d810dafaaa318a30266e585a0e7135f54"},
]
[[package]]
+2 -2
View File
@@ -23,7 +23,7 @@ repos = [
types_or = ["python", "pyi"],
args = [],
require_serial = true,
additional_dependencies = ["ruff==0.16.10"],
additional_dependencies = ["ruff==0.17.2"],
},
{
id = "ruff-format",
@@ -32,7 +32,7 @@ repos = [
language = "python",
types_or = ["python", "pyi"],
require_serial = true,
additional_dependencies = ["ruff==0.16.10"],
additional_dependencies = ["ruff==0.17.2"],
},
{
id = "basedpyright",
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "plyngent"
version = "0.11.0"
version = "0.14.2"
description = "LLM chat and agent toolkit"
authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" },
@@ -33,7 +33,7 @@ plyngent = "plyngent.cli:main"
[dependency-groups]
dev = [
"basedpyright==1.40.2",
"ruff==0.16.10",
"ruff==0.17.2",
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
+57 -18
View File
@@ -56,10 +56,20 @@ if TYPE_CHECKING:
# Per-conversation tracking of lines read with ``with_lineno=True``, keyed by
# resolved file path, with the file's mtime at read time (so stale line numbers
# after external/process mutations are detected). Set to a fresh ``dict`` at the
# start of each user turn via :func:`reset_lineno_tracker`; cleared after the
# assistant finishes. Tool handlers read/write via :func:`get_lineno_read_files` /
# :func:`lineno_read_lines` / :func:`lineno_read_mtime` / :func:`mark_lineno_read`.
# after external/process mutations are detected). The dict itself is owned by
# the :class:`ChatAgent` (a fresh one per user turn) and bound into the
# consuming task's context via :func:`reset_lineno_tracker` /
# :func:`bind_lineno_tracker`; tool handlers read/write it via
# :func:`get_lineno_read_files` / :func:`lineno_read_lines` /
# :func:`lineno_read_mtime` / :func:`mark_lineno_read`.
#
# The binding lives in the *consuming task's* context: the host wraps each turn
# attempt in its own task (auto-retry, Ctrl+C then ``/retry``), and
# ``asyncio.create_task`` copies the context of the caller — not of the previous
# attempt. Retrying therefore re-binds the *same* turn-scoped dict; without
# that, ``mark_lineno_read`` would silently drop every numbered read (the
# variable is unset in the new task) and ``edit_lineno`` would reject all edits
# even after the model re-reads the file as its own error message instructs.
_lineno_read_files: ContextVar[dict[str, tuple[set[int], int]] | None] = ContextVar(
"_lineno_read_files",
default=None,
@@ -67,7 +77,7 @@ _lineno_read_files: ContextVar[dict[str, tuple[set[int], int]] | None] = Context
def reset_lineno_tracker() -> Token[dict[str, tuple[set[int], int]] | None]:
"""Replace the tracker with a fresh empty mapping; return the previous token.
"""Bind a fresh empty tracker; return the previous token.
Call at the start of a new user turn. Restore with
``_lineno_read_files.reset(token)`` when the turn completes.
@@ -75,6 +85,18 @@ def reset_lineno_tracker() -> Token[dict[str, tuple[set[int], int]] | None]:
return _lineno_read_files.set({})
def bind_lineno_tracker(
tracker: dict[str, tuple[set[int], int]],
) -> Token[dict[str, tuple[set[int], int]] | None]:
"""Bind an agent-owned tracker dict; return the previous token.
``retry`` uses this to re-bind the current turn's dict in a new attempt
task, so numbered reads from before the failure stay valid for edits after
it (the turn did not end, so its reads should not either).
"""
return _lineno_read_files.set(tracker)
def get_lineno_read_files() -> set[str]:
"""Return the current turn's set of line-numbered file paths.
@@ -350,6 +372,9 @@ class ChatAgent:
reminder_last_band: int = 0,
provider_tools: Sequence[dict[str, object]] | None = None,
) -> None:
# Lines read with_lineno this turn (shared dict bound into each attempt
# task's context; see the module note above _lineno_read_files).
self._lineno_tracker: dict[str, tuple[set[int], int]] = {}
self.client = client
self.model = model
self.tools = tools
@@ -676,14 +701,20 @@ class ChatAgent:
*images* ride along with the text as content parts, which is the shape
the model sees an attachment in (see ``agent.images``).
"""
_ = reset_lineno_tracker()
self._ensure_system_prompt()
user_msg = UserChatMessage(content=user_content(user_text, images))
self.messages.append(user_msg)
await self._persist(user_msg)
self._persist_from = len(self.messages)
async for event in self._run_from_user_message(user_msg):
yield event
# A new user turn gets a fresh tracker; the dict is owned by the agent
# so a retry (new task, fresh context copy) can re-bind the same dict.
self._lineno_tracker = {}
token = bind_lineno_tracker(self._lineno_tracker)
try:
self._ensure_system_prompt()
user_msg = UserChatMessage(content=user_content(user_text, images))
self.messages.append(user_msg)
await self._persist(user_msg)
self._persist_from = len(self.messages)
async for event in self._run_from_user_message(user_msg):
yield event
finally:
_lineno_read_files.reset(token)
def _resolve_aside_tools(
self,
@@ -803,8 +834,16 @@ class ChatAgent:
user_msg = self.messages[user_index]
assert isinstance(user_msg, UserChatMessage)
self._ensure_system_prompt()
# Already-committed rows stay; write any the failed rollback could not.
if self._persist_from < len(self.messages):
await self._persist_range(self._persist_from, len(self.messages))
async for event in self._run_from_user_message(user_msg):
yield event
# This attempt runs in a new task whose context copy has no tracker
# bound; re-bind the turn's dict so numbered reads before the failure
# stay valid for edits after it, and reads during the retry land in the
# same dict the next tool call sees.
token = bind_lineno_tracker(self._lineno_tracker)
try:
# Already-committed rows stay; write any the failed rollback could not.
if self._persist_from < len(self.messages):
await self._persist_range(self._persist_from, len(self.messages))
async for event in self._run_from_user_message(user_msg):
yield event
finally:
_lineno_read_files.reset(token)
+22 -1
View File
@@ -58,6 +58,27 @@ class ToolOutput(Struct):
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)
class ToolConfirmRequest:
"""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:
return ToolOutput(text=denied)
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).
# Tag enforcement remains gated by ``auto_bind_state``; binding is always useful
+17 -4
View File
@@ -129,20 +129,27 @@ def _read_prompt_text(prompt: str | None, *, stdin_isatty: bool) -> str | None:
return text or None
def _setup_hooks(*, interactive: bool, auto_continue: bool, notify: NotifyConfig) -> None:
def _setup_hooks(
*,
interactive: bool,
auto_continue: bool,
notify: NotifyConfig,
workspace: Path | None = None,
) -> None:
"""Install limit hooks (workspace policy is set on ReplState).
``auto_continue`` keeps the PTY/limit hook installed for one-shot runs so
``--auto-continue`` / ``[agent] auto_continue_limits`` can raise limits
without a prompt; otherwise non-interactive runs keep today's deny. The
prompt-wait notifier only makes sense for a human at a terminal, so a
non-interactive run clears it whatever ``[notify]`` says.
non-interactive run clears it whatever ``[notify]`` says. *workspace* is
what a notification names, so two chats are told apart.
"""
from plyngent.cli.notify import install_notifier
from plyngent.prompting import set_prompt_wait_hook
if interactive:
_ = install_notifier(notify)
_ = install_notifier(notify, workspace=workspace)
else:
set_prompt_wait_hook(None)
if interactive or auto_continue:
@@ -258,7 +265,12 @@ async def _run_chat( # noqa: C901, PLR0915 — chat orchestration
_warn_recoverable_providers(store.recoverable_providers)
auto_effective = auto_continue or store.agent_config.auto_continue_limits
_setup_hooks(interactive=interactive, auto_continue=auto_effective, notify=store.notify_config)
_setup_hooks(
interactive=interactive,
auto_continue=auto_effective,
notify=store.notify_config,
workspace=workspace,
)
# --yes forces sticky YOLO; else derive from config.confirm_destructive.
from plyngent.cli.state import YoloMode
@@ -351,6 +363,7 @@ async def _run_chat( # noqa: C901, PLR0915 — chat orchestration
# instance.workspace (no process bag).
state.instance_state.workspace.path_denylist = tuple(store.agent_config.path_denylist or ())
state.instance_state.workspace.trash_enabled = store.trash_config.enabled
state.sync_image_capability()
from plyngent.tools.net import set_ssrf_assume_public_cidrs
# 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_ARGS_PREVIEW = 80
_ASK_SUBJECT_PREVIEW = 60
_PTY_PAYLOAD_PREVIEW = 40
# ``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.
_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:
argv = _json_list_arg(args_json, "command")
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),
"open_pty": PrettyLine(prefix=_open_pty_prefix, detail=_open_pty_detail),
"read_pty": _pty_session_line("Read", _read_pty_detail),
"write_pty": _pty_session_line("Write", _pty_write_detail),
"write_pty_keys": _pty_session_line("Keys", _pty_write_detail),
"write_pty": PrettyLine(prefix=_pty_write_prefix("Write"), detail=_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),
"close_pty": _pty_session_line("Close", _close_pty_detail),
"ask_user_line": _ask_line("", "question", _ask_detail),
+92 -30
View File
@@ -6,6 +6,7 @@ import shutil
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Literal
from plyngent.agent import BatchConfirmDecision, ToolConfirmRequest
@@ -32,7 +33,6 @@ from plyngent.tools.workspace import AccessMode, parse_access_mode
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from pathlib import Path
type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"]
@@ -69,11 +69,33 @@ _CONTINUE_PROMPT = "Raise limit and continue? [Y/n/yyy] (yyy = stop asking this
_CONTINUE_YES_TOKENS = frozenset({"", "y", "yes"})
_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
# at a time through the per-call prompt.
_BATCH_YES_TOKENS = frozenset({"y", "yes"})
_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
class _AutoContinue:
@@ -202,9 +224,22 @@ def _decide_continue_limit(reason: str) -> bool:
backend = get_prompt_backend()
if not backend.is_interactive():
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)
token = backend.read_line(_CONTINUE_PROMPT, default="y").strip().lower()
source = _answer_source(wait)
if source is None:
token = backend.read_line(_CONTINUE_PROMPT, default="y").strip().lower()
else:
# Same read as the timed confirms: stdin or the notification,
# whichever answers first, with the terminal's default on silence.
# The prompt line still goes to the terminal: the buttons elsewhere
# do not say what typing here would do.
with contextlib.suppress(OSError):
_ = sys.stderr.write(_CONTINUE_PROMPT)
_ = sys.stderr.flush()
token = (_read_answer(None, wait) or "").strip().lower()
if not token:
token = "y"
if token == _CONTINUE_AUTO_TOKEN:
_auto_continue.turn = True
return True
@@ -236,29 +271,37 @@ async def prompt_continue_limit_async(reason: str) -> bool:
def _confirm_with(wait: PromptAnswerSource | None, prompt: str, *, default: bool) -> bool:
"""Yes/no, also taking an answer that arrived through a notification."""
source = _answer_source(wait)
if source is None:
return confirm(prompt, default=default)
suffix = "[Y/n]" if default else "[y/N]"
with contextlib.suppress(OSError):
_ = sys.stderr.write(f"{prompt} {suffix}: ")
_ = sys.stderr.flush()
try:
raw = _read_answer(None, wait)
except (KeyboardInterrupt, EOFError) as exc:
msg = "prompt cancelled"
raise NonInteractiveError(msg) from exc
token = (raw or "").strip().lower()
if not token:
return default
return token in {"y", "yes"}
return confirm(prompt, default=default, source=_answer_source(wait))
def _tool_facts(args: Mapping[str, object]) -> tuple[str, ...]:
"""What a call is about, short enough to travel in a notification.
Only the *object* — the program or the file — never an argument, a URL or a
piece of code: those stay in the reason, which a notification carries only
when ``include_reason`` allows it.
"""
facts: list[str] = []
argv = args.get("argv")
if isinstance(argv, list) and argv and isinstance(argv[0], str):
facts.append(f"program: {Path(argv[0]).name}")
for key in ("path", "dst", "src"):
value = args.get(key)
if isinstance(value, str) and value:
facts.append(f"target: {Path(value).name}")
break
return tuple(facts)
def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool | str:
"""True allow; False deny; non-empty str = deny with comment for the model."""
del args
try:
with prompt_wait("confirm", f"tool {name!r} needs confirmation", reason) as wait:
with prompt_wait(
"confirm",
f"tool {name!r} needs confirmation",
reason,
facts=_tool_facts(args),
) as wait:
_echo_tool_confirm(name, reason)
allowed = _confirm_with(wait, "Allow this tool call?", default=False)
except NonInteractiveError:
@@ -372,7 +415,7 @@ def prompt_policy_command_confirm(
try:
with (
pause_task_cancel_for_prompt(),
prompt_wait("policy", f"allow command {basename!r}?", reason) as wait,
prompt_wait("policy", f"allow command {basename!r}?", reason, expires_in=timeout_seconds) as wait,
):
backend = get_prompt_backend()
if not backend.is_interactive():
@@ -419,7 +462,7 @@ def prompt_policy_fetch_confirm(
try:
with (
pause_task_cancel_for_prompt(),
prompt_wait("policy", f"allow fetch_local {host!r}?", reason) as wait,
prompt_wait("policy", f"allow fetch_local {host!r}?", reason, expires_in=timeout_seconds) as wait,
):
backend = get_prompt_backend()
if not backend.is_interactive():
@@ -468,7 +511,14 @@ def prompt_skill_write_confirm(
try:
with (
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,
facts=(f"path: {skill_dir}",),
expires_in=timeout_seconds,
) as wait,
):
backend = get_prompt_backend()
if not backend.is_interactive():
@@ -509,9 +559,10 @@ def prompt_directory_access_confirm(
that level instead. The host's hook decides YOLO/``--yes`` before calling
this. Timeout, cancel, or non-interactive → ``None`` (deny).
"""
kind = "directory" if path.is_dir() else "file"
lines = [
f"request: {mode.name.lower()} access to {path}",
f"kind: {'directory' if path.is_dir() else 'file'}",
f"kind: {kind}",
]
if reason:
lines.append(f"why: {reason}")
@@ -519,7 +570,13 @@ def prompt_directory_access_confirm(
try:
with (
pause_task_cancel_for_prompt(),
prompt_wait("access", f"allow {mode.name.lower()} access to {path}?", reason) as wait,
prompt_wait(
"access",
f"allow {mode.name.lower()} access to {path}?",
reason,
facts=(f"kind: {kind}",),
expires_in=timeout_seconds,
) as wait,
):
backend = get_prompt_backend()
if not backend.is_interactive():
@@ -558,11 +615,11 @@ def prompt_directory_access_confirm(
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool | str:
"""Async confirm: True allow, False deny, str = deny with user comment."""
del args
facts = _tool_facts(args)
try:
# Box is printed inside the worker thread via confirm's backend.
def _run() -> bool:
with prompt_wait("confirm", f"tool {name!r} needs confirmation", reason) as wait:
with prompt_wait("confirm", f"tool {name!r} needs confirmation", reason, facts=facts) as wait:
_echo_tool_confirm(name, reason)
return _confirm_with(wait, "Allow this tool call?", default=False)
@@ -605,8 +662,13 @@ def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchC
if not backend.is_interactive():
# Non-interactive: deny, like the default of the single prompt.
return BatchConfirmDecision(allowed=False)
names = ", ".join(request.name for request in requests)
with prompt_wait("batch", f"{len(requests)} tool calls need confirmation", names) as wait:
tools = tuple(dict.fromkeys(request.name for request in requests))
with prompt_wait(
"batch",
f"{len(requests)} tool calls need confirmation",
choices=_BATCH_CHOICES,
facts=tools,
) as wait:
backend.echo()
backend.secho(format_batch_confirm_box(requests), fg="yellow")
backend.secho("m = review them one call at a time", fg="bright_black")
+268 -41
View File
@@ -3,13 +3,22 @@
A prompt blocks the turn, so someone who switched to another window learns about
it only when they come back. The notifier announces a prompt once it has waited
``grace_seconds`` — answering at the terminal stays silent — and, where the
platform allows it, offers the answer itself: allow / deny / review buttons for
a confirmation, a reply field for a question.
platform allows it, offers the answer itself: the prompt's own answers as
buttons (allow / deny for a confirmation), a reply field for a question. It says
what is asked: a question travels in the body where it is read whole, the object
of a confirmation (a program or a file name) rides along with it, a prompt that
takes a default answer says when not answering gives it, and the workspace it
belongs to comes last. 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
(``pip install plyngent[notify]``); the terminal one writes an OSC escape
sequence to the controlling terminal, which every modern terminal turns into a
native notification without any dependency at all.
native notification without any dependency at all. A notification that went to
the desktop also rings the terminal's bell: the popup may be on another virtual
desktop, and no desktop lets a program raise another application's window.
"""
from __future__ import annotations
@@ -17,6 +26,7 @@ from __future__ import annotations
import asyncio
import contextlib
import importlib.util
import math
import os
import sys
import threading
@@ -36,19 +46,42 @@ if TYPE_CHECKING:
ALLOW = "allow"
DENY = "deny"
REVIEW = "review"
#: Kinds whose notification carries answer buttons (``ask`` gets a reply field).
_ANSWER_KINDS = frozenset({"confirm", "batch", "policy", "access", "skills"})
#: Kinds whose notification carries an answer, and the capability it needs: a
#: 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",
}
#: The answers a confirmation offers when the prompt names none of its own.
_BUTTONS: tuple[tuple[str, str], ...] = (
(ALLOW, "Allow once"),
(DENY, "Deny"),
(REVIEW, "Go to terminal"),
)
_MAX_TITLE = 80
_MAX_MESSAGE = 200
#: One body line is capped so a single long line cannot starve the others.
_MAX_BODY_LINE = 100
#: Prompt families a notification never announces: the value being asked for
#: would have to travel back through a surface that outlives the prompt.
_QUIET_KINDS = frozenset({"secret"})
#: What not answering means, for a prompt that has a timed default.
_DEADLINE = "no answer in {seconds:g}s = deny"
#: 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:
@@ -58,6 +91,70 @@ def _clean(text: str, limit: int) -> str:
return stripped if len(stripped) <= limit else stripped[: limit - 1] + "…"
def _deadline(wait: PromptWait) -> str:
"""What not answering means, when the prompt takes a default answer."""
if wait.expires_in is None:
return ""
return _clean(_DEADLINE.format(seconds=wait.expires_in), _MAX_BODY_LINE)
def _expiry(wait: PromptWait) -> int | None:
"""How long the popup may stay up: as long as the prompt waits for an answer.
``None`` keeps it until it is dismissed — a prompt with no deadline has no
moment at which the notification is stale.
"""
if wait.expires_in is None or wait.expires_in <= 0:
return None
return max(1, math.ceil(wait.expires_in))
def _shorten_home(path: Path) -> str:
"""The path as the human types it: ``~`` for the home directory."""
home = Path.home()
if path == home:
return "~"
with contextlib.suppress(ValueError):
return f"~/{path.relative_to(home).as_posix()}"
return str(path)
def _body_parts(wait: PromptWait, *, include_reason: bool) -> list[str]:
"""The lines the prompt asks for, in reading order.
The question first, then the reason when the config allows it, then what the
prompt is about: a host that hides the reason still says what is being asked
and what it concerns.
"""
parts: list[str] = []
if wait.body:
parts.append(wait.body)
if include_reason and wait.detail:
parts.append(wait.detail)
if wait.facts:
parts.append(" · ".join(wait.facts))
return [_clean(part, _MAX_BODY_LINE) for part in parts]
def _body(wait: PromptWait, *, include_reason: bool, multiline: bool, source: str = "") -> str:
"""The notification body, with the deadline and the source kept for last.
The lines are filled in reading order until the budget is gone — a long
reason drops out before the deadline does — and each one is capped, so no
single line can starve the rest. A desktop popup wraps, so its body keeps
the lines apart; a terminal escape sequence carries the body on one line.
"""
tail = [line for line in (_deadline(wait), _clean(source, _MAX_BODY_LINE)) if line]
budget = _MAX_MESSAGE - sum(len(line) + 1 for line in tail)
kept: list[str] = []
for line in _body_parts(wait, include_reason=include_reason):
if not line or len(line) + 1 > budget:
continue
kept.append(line)
budget -= len(line) + 1
return "\n".join([*kept, *tail]) if multiline else " ".join([*kept, *tail])
# --------------------------------------------------------------------------- #
# Desktop channel (optional `desktop-notifier` extra)
# --------------------------------------------------------------------------- #
@@ -73,6 +170,9 @@ class DesktopRequest:
reply_field: bool = False
on_answer: Callable[[str], None] | None = None
on_reply: Callable[[str], None] | None = None
on_click: Callable[[], None] | None = None
#: Seconds the popup may stay up; ``None`` keeps it until it is dismissed.
timeout: int | None = None
@runtime_checkable
@@ -161,8 +261,16 @@ class DesktopNotifierChannel:
send(
title=request.title,
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: it stays up until it is dismissed — or, for a prompt
# that gives up on its own, until that deadline.
timeout=_NO_EXPIRY if request.timeout is None else request.timeout,
buttons=buttons,
reply_field=reply,
# Clicking the body is the default action: it takes the
# human back to the terminal instead of answering.
on_clicked=request.on_click,
)
)
)
@@ -208,6 +316,7 @@ def load_desktop_channel() -> DesktopChannel | None:
# --------------------------------------------------------------------------- #
_OSC9 = "\x1b]9;{title}: {message}\x07"
_OSC9_TITLE_ONLY = "\x1b]9;{title}\x07"
_OSC777 = "\x1b]777;notify;{title};{message}\x07"
_BELL = "\a"
#: VTE (GNOME Terminal, Tilix, Terminator, …) speaks OSC 777 from this version on.
@@ -258,7 +367,9 @@ def terminal_sequence(protocol: str, title: str, message: str, *, multiplexer: s
safe_title = _clean(title, _MAX_TITLE)
safe_message = _clean(message, _MAX_MESSAGE)
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:
seq = _OSC777.format(title=safe_title, message=safe_message)
if multiplexer == "tmux":
@@ -312,14 +423,20 @@ class WaitHandle:
notifier: WaitNotifier
wait: PromptWait
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
#: 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:
self.notifier = notifier
self.wait = wait
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._timer: threading.Timer | None = None
self._closed: bool = False
@@ -332,17 +449,34 @@ class WaitHandle:
return None
def answer(self, key: str) -> None:
"""Record one answer from the notification (a button, or a reply)."""
"""Record one answer pressed on the notification (a button)."""
if self._closed:
return
if key == REVIEW and self.wait.kind != "ask":
# "review" means "I will answer in the terminal": withdraw and wait.
self.notifier.dismiss(self)
return
# 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)
def reply(self, text: str) -> None:
"""Record one answer typed into the notification's reply field."""
if self._closed:
return
self.notifier.handoff()
self._answers.put(text)
def withdraw(self) -> None:
"""Take the notification down; the prompt keeps waiting at the terminal.
This is what a click on the body does, and the popup's own close button
means the same. Nothing can raise another application's window (no
desktop exposes that portably), which is why the bell — rung as the
notification went out — is what said where the prompt is.
"""
self.notifier.dismiss(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
self._timer = timer
timer.start()
@@ -361,10 +495,15 @@ class WaitHandle:
self.notifier.dismiss(self)
def _notify(self) -> None:
if self._closed or not self.notifier.may_notify():
if self._closed or not self.notifier.may_notify(self):
return
if not self.notifier.notify_desktop(self):
self.notifier.notify_terminal(self.wait)
if self.notifier.notify_desktop(self):
# The popup went to the desktop, so the human is looking at some
# other window — possibly another virtual desktop, where a popup is
# easy to miss. The terminal rings to say which window waits.
self.notifier.ring()
return
self.notifier.notify_terminal(self.wait)
class WaitNotifier:
@@ -377,10 +516,13 @@ class WaitNotifier:
_config: NotifyConfig
_resolve_desktop: bool
_desktop: DesktopChannel | None
_resolve_lock: threading.Lock
_write: Callable[[str], None]
_env: Mapping[str, str]
_clock: Callable[[], float]
_last_sent: float
_handoff: bool
_source: str
def __init__(
self,
@@ -390,6 +532,7 @@ class WaitNotifier:
write: Callable[[str], None] | None = None,
env: Mapping[str, str] | None = None,
clock: Callable[[], float] = time.monotonic,
workspace: Path | str | None = None,
) -> None:
self._config = config
if isinstance(desktop, _Resolve):
@@ -398,10 +541,15 @@ class WaitNotifier:
else:
self._resolve_desktop = False
self._desktop = desktop
self._resolve_lock = threading.Lock()
self._write = write or write_terminal
self._env = os.environ if env is None else env
self._clock = clock
self._last_sent = float("-inf")
self._handoff = False
self._source = ""
if workspace is not None:
self.set_workspace(workspace)
# -- hook ------------------------------------------------------------- #
@@ -412,33 +560,110 @@ class WaitNotifier:
def grace_seconds(self) -> float:
return self._config.grace_seconds
def set_workspace(self, workspace: Path | str | None) -> None:
"""Name the directory the prompt belongs to (``~`` for a home path).
A chat can be rebound to another workspace, so the label is kept on the
notifier rather than baked into a notification: several chats look
alike, and which one is waiting is the first thing a human needs.
"""
self._source = "" if workspace is None else _shorten_home(Path(workspace))
def ring(self) -> None:
"""Ring the terminal's bell: the prompt is waiting *there*."""
self._write(_BELL)
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:
channels = self._config.channels
return channel in channels or "auto" in channels
def may_notify(self) -> bool:
"""Whether a notification is worth sending right now."""
def may_notify(self, handle: WaitHandle) -> bool:
"""Whether this prompt is worth announcing right now."""
if not self._config.enabled or not get_prompt_backend().is_interactive():
return False
# A secret is never announced: its value would have to come back
# through a surface that outlives the prompt.
if handle.wait.kind in _QUIET_KINDS:
return False
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
self._last_sent = now
return True
def _message(self, wait: PromptWait) -> str:
title = _clean(wait.title, _MAX_TITLE)
if self._config.include_reason and wait.detail:
return f"{title}\n{_clean(wait.detail, _MAX_MESSAGE)}"
return title
"""The one-line body the terminal channel carries."""
return _body(
wait,
include_reason=self._config.include_reason,
multiline=False,
source=self._source,
)
def _desktop_message(self, wait: PromptWait) -> str:
"""The body the desktop channel carries: the same lines, kept apart."""
return _body(
wait,
include_reason=self._config.include_reason,
multiline=True,
source=self._source,
)
def _buttons(self, wait: PromptWait, capability: str) -> tuple[tuple[str, str], ...]:
"""The answers *wait* accepts; a reply prompt has none to offer."""
if capability == "reply":
# The reply field carries the answer; a button beside it would only
# be a way out, and the popup's own close is that.
return ()
return wait.choices or _BUTTONS
def desktop_channel(self) -> DesktopChannel | None:
"""The desktop surface, resolved once (importing the extra on demand)."""
if self._resolve_desktop:
self._desktop = load_desktop_channel()
self._resolve_desktop = False
with self._resolve_lock:
if self._resolve_desktop:
self._desktop = load_desktop_channel()
self._resolve_desktop = False
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:
"""Show the desktop notification; False when it could not be shown."""
if not self._wants("desktop"):
@@ -447,25 +672,22 @@ class WaitNotifier:
if channel is None:
return False
wait = handle.wait
capabilities = channel.capabilities()
buttons: tuple[tuple[str, str], ...] = ()
if self._config.actions and wait.kind in _ANSWER_KINDS and "buttons" in capabilities:
buttons = _BUTTONS
reply = self._config.actions and wait.kind == "ask" and "reply" in capabilities
capability = self.answer_capability(wait.kind) or ""
try:
handle.identifier = channel.send(
DesktopRequest(
title=_clean(wait.title, _MAX_TITLE),
message=self._message(wait),
buttons=buttons,
reply_field=reply,
message=self._desktop_message(wait),
buttons=self._buttons(wait, capability) if capability else (),
reply_field=capability == "reply",
on_answer=handle.answer,
on_reply=handle.answer,
on_reply=handle.reply,
on_click=handle.withdraw,
timeout=_expiry(wait),
)
)
except Exception: # noqa: BLE001 - a notification never breaks a prompt
return False
handle.supports_actions = bool(buttons) or reply
return True
def notify_terminal(self, wait: PromptWait) -> None:
@@ -491,11 +713,16 @@ class WaitNotifier:
channel.clear(identifier)
def install_notifier(config: NotifyConfig) -> WaitNotifier | None:
"""Install the prompt-wait hook from ``[notify]`` (None clears it)."""
def install_notifier(config: NotifyConfig, *, workspace: Path | str | None = None) -> WaitNotifier | None:
"""Install the prompt-wait hook from ``[notify]`` (None clears it).
*workspace* is the directory the chat runs in: a notification names it, so
two waiting chats are told apart.
"""
if not config.enabled:
set_prompt_wait_hook(None)
return None
notifier = WaitNotifier(config)
notifier = WaitNotifier(config, workspace=workspace)
notifier.warm()
set_prompt_wait_hook(notifier)
return notifier
+30
View File
@@ -108,6 +108,7 @@ class ReplState:
self.workspace = Path(self.workspace).expanduser().resolve()
self.instance_state.workspace_root = self.workspace
self.instance_state.workspace.root = self.workspace
self._retarget_notifier(self.workspace)
self._install_config_access()
self._install_skills()
from plyngent.cli.limits import set_auto_continue_default
@@ -507,6 +508,7 @@ class ReplState:
# Preserve live stream toggle if agent already exists.
if hasattr(self, "agent"):
self.stream_enabled = self.agent.stream
self.sync_image_capability()
self.client = create_client(self.provider, model=self.model, reasoning=self.reasoning)
self.agent = self._make_agent()
# Restore history without re-marking already-stored messages as dirty.
@@ -517,6 +519,21 @@ class ReplState:
if self._remote_models_key is not None and self._remote_models_key != self._models_cache_key():
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]:
# Remote model lists belong to the provider endpoint used for discovery,
# not model-level overrides (the model is not selected yet there).
@@ -695,11 +712,24 @@ class ReplState:
self.workspace = resolved
self.instance_state.workspace_root = resolved
self.instance_state.workspace.root = resolved
# A prompt announced from here names this directory, not the one the
# process started in.
self._retarget_notifier(resolved)
# Project-scope skill roots follow the workspace.
self._install_skills()
if hasattr(self, "agent") and self.agent.tools is not None:
self.agent.tools.set_instance_state(self.instance_state)
@staticmethod
def _retarget_notifier(workspace: Path) -> None:
"""Point an installed prompt notifier at *workspace* (if there is one)."""
from plyngent.cli.notify import WaitNotifier
from plyngent.prompting import get_prompt_wait_hook
hook = get_prompt_wait_hook()
if isinstance(hook, WaitNotifier):
hook.set_workspace(workspace)
def _apply_session_workspace(self, row: SessionRow) -> None:
"""Bind tools/REPL workspace to the session's directory when set."""
if not row.workspace:
+2 -1
View File
@@ -284,7 +284,8 @@ class NotifyConfig(Struct, omit_defaults=True):
"""Desktop notifications shown while a prompt waits on the human.
A prompt is announced only after ``grace_seconds`` of waiting, so answering
at the terminal stays silent; ``min_interval_seconds`` throttles bursts.
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
back to the terminal, ``desktop`` / ``terminal`` pin one of them.
+81 -10
View File
@@ -125,13 +125,27 @@ class PromptWait:
``kind`` names the prompt family (``confirm``, ``ask``, ``secret``,
``choose``, ``policy``, ``access``, ``skills``, ``limit``) so the host can
tailor what it shows and which answers it offers; ``title`` is short and
safe to surface anywhere, while ``detail`` carries one optional line the
caller has already redacted.
safe to surface anywhere. ``body`` is the prompt itself — a question, which
the host shows as it stands — while ``detail`` carries one optional reason
line the caller has already redacted: it can name a command or a URL, so a
host shows it only when the human allowed that. ``facts`` are short items
naming the *object* of the prompt (a program or a file name, never an
argument): they travel unconditionally, so a host that hides the reason
still says what the prompt is about. ``expires_in`` is how long the prompt
waits before taking its default answer, which is usually "deny" — a host
says so, because not answering is then an answer. ``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
title: str
detail: str = ""
choices: tuple[tuple[str, str], ...] = ()
body: str = ""
facts: tuple[str, ...] = ()
expires_in: float | None = None
@runtime_checkable
@@ -140,7 +154,12 @@ class PromptAnswerSource(Protocol):
@property
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:
@@ -171,9 +190,23 @@ def get_prompt_wait_hook() -> PromptWaitHook | None:
@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], ...] = (),
*,
body: str = "",
facts: tuple[str, ...] = (),
expires_in: float | None = None,
) -> Generator[PromptAnswerSource | None]:
"""Announce a blocking prompt to the host; yields its answer source (or None).
``choices`` says which answers this prompt accepts, ``body`` what it asks,
``facts`` what it is about and ``expires_in`` when its default answer takes
over (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
a follow-up question while it is already on screen must not notify — and
interrupt — a second time.
@@ -184,7 +217,8 @@ def prompt_wait(kind: str, title: str, detail: str = "") -> Generator[PromptAnsw
return
token = _wait_depth.set(_wait_depth.get() + 1)
try:
with hook(PromptWait(kind, title, detail)) as source:
wait = PromptWait(kind, title, detail, choices, body, facts, expires_in)
with hook(wait) as source:
yield source
finally:
_wait_depth.reset(token)
@@ -537,6 +571,15 @@ def _read_with_source(
return answer.strip()
#: Titles for the prompts whose own text *is* the question: a host gives the
#: title a line of its own and cuts it short, so the question itself goes into
#: the body, where it is read whole.
_ASK_TITLE = "a question is waiting"
_CHOOSE_TITLE = "a choice is waiting"
#: How many option labels a choice prompt names alongside its question.
_MAX_CHOICE_FACTS = 4
def ask(
prompt: str,
*,
@@ -551,7 +594,7 @@ def ask(
if not backend.is_interactive() and default is None:
msg = f"non-interactive: cannot prompt for {prompt!r}"
raise NonInteractiveError(msg)
with prompt_wait("ask", prompt) as wait:
with prompt_wait("ask", _ASK_TITLE, body=prompt) as wait:
backend.secho(prompt, fg="yellow")
source = wait if wait is not None and wait.supports_actions else None
return _read_with_source("Answer", source, default=default, completions=completions)
@@ -560,6 +603,10 @@ def ask(
def ask_secret(prompt: str) -> str:
"""Prompt for a secret line (no echo). Never use for values that return to the model.
The ``secret`` family is announced, never answered: the prompt is on screen
at the terminal only, since a value typed into a notification would have to
travel to get back to the process.
Cancel (Ctrl+C / EOF) raises :class:`NonInteractiveError`.
"""
backend = get_prompt_backend()
@@ -599,8 +646,9 @@ def choose(
msg = f"non-interactive: cannot prompt for {prompt!r}"
raise NonInteractiveError(msg)
facts = tuple(dict.fromkeys(option.label for option in choices[:_MAX_CHOICE_FACTS]))
while True:
with prompt_wait("choose", prompt):
with prompt_wait("choose", _CHOOSE_TITLE, body=prompt, facts=facts):
raw = backend.read_line(
"Choice",
default=default_display,
@@ -619,8 +667,26 @@ def choose(
backend.echo("Invalid choice; pick a listed option.")
def confirm(prompt: str, *, default: bool = False) -> bool:
"""Yes/no confirm via the active backend.
#: Answers a surface spells out on top of the ``y`` a confirm already accepts.
_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.
Cancel (Ctrl+C / EOF) raises :class:`NonInteractiveError`.
@@ -628,8 +694,13 @@ def confirm(prompt: str, *, default: bool = False) -> bool:
backend = get_prompt_backend()
if not backend.is_interactive():
return default
if source is not None:
return _confirm_from_source(prompt, source, default=default)
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)
+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 grant_policy_command as grant_policy_command
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 match_command_denylist as match_command_denylist
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 resolve_path as resolve_path
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_policy_confirm_hook as set_policy_confirm_hook
from .workspace import set_policy_confirm_timeout as set_policy_confirm_timeout
+315 -157
View File
@@ -9,6 +9,7 @@ program — further in.
from __future__ import annotations
import getopt
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING
@@ -19,6 +20,7 @@ if TYPE_CHECKING:
__all__ = [
"ESCALATION_PROGRAMS",
"CommandString",
"RemoteTarget",
"UnwrappedCommand",
"basename",
"dash_c_code",
@@ -27,34 +29,9 @@ __all__ = [
"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.
_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
# 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.
@@ -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``.
_DURATION = re.compile(r"[0-9]+(?:\.[0-9]+)?[smhd]?")
# Options that consume the next token, so ``sudo -u root python`` still finds
# ``python``; jammed forms (``-n10``, ``--with=pkg``) need no entry, and unknown
# options are skipped bare. List value-taking options only: an entry for a flag
# eats the program behind it, which hid ``rm`` from ``exec -c rm -rf /`` and
# judged ``exec -c git -c … init`` as ``init``. An option with an *optional*
# value (``xargs -e``, ``watch -d``) needs no entry either — its bare form runs
# the program, and the jammed form carries the value in the same token.
_VALUE_OPTS: dict[str, frozenset[str]] = {
"doas": frozenset({"-u", "--user"}),
"env": frozenset({"-C", "-S", "-u", "--chdir", "--split-string", "--unset"}),
"exec": frozenset({"-a"}),
"ionice": frozenset({"-c", "-n", "-p", "-P", "-u"}),
"nice": frozenset({"-n", "--adjustment"}),
"pkexec": frozenset({"--user"}),
"stdbuf": frozenset({"-e", "-i", "-o"}),
"su": frozenset({"-c", "-g", "-G", "-s", "-u", "-w", "--command", "--group", "--session-command", "--shell"}),
"sudo": frozenset(
{
"-C",
"-D",
"-g",
"-h",
"-p",
"-r",
"-R",
"-t",
"-T",
"-u",
"-U",
"--close-from",
"--command-timeout",
"--group",
"--host",
"--other-user",
"--prompt",
"--role",
"--type",
"--user",
}
# Every wrapper's own options, spelled the way ``getopt`` reads them: a short
# option with ``:`` (``u:``) takes a value, a bare one is a flag, and a long
# option ending in ``=`` takes one. The flags matter as much as the values — a
# value-only list let a flag eat the program behind it (``exec -c rm -rf /``
# resolved to the ``-c``, hiding ``rm``), and an unknown option is no longer
# guessed at (see `_parse_options`).
@dataclass(frozen=True)
class _Spec:
"""How one wrapper is invoked, so the program behind it can be found."""
short: str = ""
long: tuple[str, ...] = ()
#: Options whose value is a command line the wrapper runs (``env -S``).
command_opts: frozenset[str] = frozenset()
#: Takes a bare duration of its own before its command (``timeout 5 cmd``).
duration: bool = False
#: ``<launcher> run <program>``: only the ``run`` form wraps a program.
launcher: bool = False
#: Runs its command on another machine (``ssh`` / ``mosh``).
remote: bool = False
_WRAPPERS: dict[str, _Spec] = {
"command": _Spec(short="pvV"),
"doas": _Spec(short="C:nsu:", long=("user=",)),
# ``env -S`` / ``su -c`` carry a *command line*: the value is code the review
# must surface (`_Spec.command_opts`), not an argument.
"env": _Spec(
short="0iC:u:S:v",
long=("chdir=", "debug", "ignore-environment", "null", "split-string=", "unset="),
command_opts=frozenset({"--split-string", "-S"}),
),
"exec": _Spec(short="cla:"),
"ionice": _Spec(short="c:n:p:P:u:thV"),
"nice": _Spec(short="n:", long=("adjustment=",)),
"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
# code rather than an argument: ``env -S "rm -rf /"`` and ``su -c "…"``. An
# interpreter's own ``-c`` is the same idea, but there the interpreter is the
# program under review; here a wrapper hides the command, so the review has to
# surface it (:func:`plyngent.tools.danger.classify_danger`).
_COMMAND_STRING_OPTS: dict[str, frozenset[str]] = {
"env": frozenset({"-S", "--split-string"}),
"su": frozenset({"-c", "--command", "--session-command"}),
# ``<launcher> run <program> …``: the option sets of the launchers below differ,
# so one union stands in for all of them — a value option is the only kind that
# can swallow the program, and the common flags are listed to keep them quiet.
_LAUNCHER_SPEC = _Spec(
short="C:e:F:G:m:n:p:s:w:",
long=(
"all-extras",
"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``).
@@ -226,6 +329,18 @@ class CommandString:
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)
class UnwrappedCommand:
"""A command plus the program it really runs, past wrappers and launchers."""
@@ -236,6 +351,10 @@ class UnwrappedCommand:
base: str
self_review: tuple[str, ...]
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
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.
Wrappers are unwrapped outer → inner as long as a program still follows
them; their own options and ``FOO=bar`` assignments are skipped, and an
option that carries a command line is collected in ``command_strings``.
them; their own options and ``FOO=bar`` assignments are read by ``getopt``
(: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)
wrappers: list[str] = []
command_strings: list[CommandString] = []
remote: RemoteTarget | None = None
unparsed = ""
index = 0
while index < len(parts):
name = basename(parts[index])
nxt = _skip_wrapper(name, parts, index + 1, command_strings=command_strings)
if nxt is None:
step = _step(name, parts, index + 1, command_strings=command_strings)
if step.unparsed:
unparsed = f"{name} {step.unparsed}"
break
if step.remote is not None:
remote = step.remote
if step.index is None:
break
wrappers.append(name)
index = nxt
index = step.index
base = basename(parts[index]) if index < len(parts) else ""
chain = dict.fromkeys((*wrappers, base))
return UnwrappedCommand(
@@ -310,88 +448,108 @@ def unwrap_command(argv: Sequence[str]) -> UnwrappedCommand:
base=base,
self_review=tuple(name for name in chain if name in _SELF_REVIEW),
command_strings=tuple(command_strings),
remote=remote,
unparsed=unparsed,
)
def _skip_wrapper(
name: str,
parts: list[str],
start: int,
*,
command_strings: list[CommandString],
) -> int | None:
"""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
@dataclass(frozen=True)
class _Parsed:
"""A wrapper's own options, where its command starts, and what it did not know."""
options: tuple[tuple[str, str], ...] = ()
index: int = 0
unparsed: str = ""
def _record_command_string(
name: str,
option: str,
text: str,
command_strings: list[CommandString],
) -> None:
"""Note *text* when *option* hands a command line to wrapper *name*."""
if option in _COMMAND_STRING_OPTS.get(name, frozenset()):
command_strings.append(CommandString(wrapper=name, option=option, text=text))
@dataclass(frozen=True)
class _Step:
"""One wrapper resolved: its command, the hop it reaches, or its parse error."""
index: int | None = None
unparsed: str = ""
remote: RemoteTarget | None = None
def _skip_options(
parts: list[str],
start: int,
*,
name: str,
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."""
def _step(name: str, parts: list[str], start: int, *, command_strings: list[CommandString]) -> _Step:
"""Resolve the wrapper *name* at ``parts[start - 1]`` past its own options."""
spec = _spec_for(name)
if spec is None:
return _Step()
index = start
took_duration = False
while index < len(parts):
token = parts[index]
if token == "--":
return index + 1
if _ASSIGNMENT.match(token):
index += 1
continue
if token.startswith("-"):
index += 1
if token in value_opts:
value = parts[index] if index < len(parts) else ""
_record_command_string(name, token, value, command_strings)
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
if spec.launcher:
if index >= len(parts) or parts[index] != "run":
return _Step() # ``npm install``: the launcher is the whole command
index += 1
parsed = _parse_options(parts, index, spec)
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
if spec.duration and index < len(parts) and _DURATION.fullmatch(parts[index]):
index += 1
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
reason of its own: the denylist gate in the handler always asks about the
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)
interpreter = is_interpreter(scan.base)
findings = [f"{name} (detached run)" for name in scan.self_review]
if interpreter:
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 = list(dict.fromkeys(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.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:
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.
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:
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}"
if not target.is_file():
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))
if isinstance(part, str):
return f"error: {path}: {part.removeprefix('error: ')}"
+6 -1
View File
@@ -19,6 +19,7 @@ from plyngent.tools.net.policy import (
soft_confirm_reason,
)
from plyngent.tools.truncate_token import truncate_to_memory
from plyngent.tools.workspace import image_input_refusal
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.
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:
return text
refusal = image_input_refusal()
if refusal is not None:
return f"{text}\n{refusal}"
part = image_part_from_bytes(result.body_image)
if isinstance(part, str):
return f"{text}\n{part}"
+185 -24
View File
@@ -10,11 +10,9 @@ from __future__ import annotations
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_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 = {
r"\n": "\n",
r"\r": "\r",
@@ -23,18 +21,84 @@ _SIMPLE = {
r"\E": "\x1b",
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",
"escape": "\x1b",
"enter": "\r",
"tab": "\t",
"s-tab": "\x1b[Z",
"space": " ",
"bs": "\x7f",
"del": "\x1b[3~",
"ins": "\x1b[2~",
"home": "\x1b[H",
"end": "\x1b[F",
"up": "\x1b[A",
"down": "\x1b[B",
"right": "\x1b[C",
"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).
_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)
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:
"""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``
- ``\\xHH`` byte, ``\\uHHHH`` Unicode code point
- ``ctrl+c`` / ``ctrl+x`` ... (case-insensitive; A-Z or @ [ \\ ] ^ _)
- ``key=esc`` ``key=enter`` ``key=tab`` ``key=up|down|left|right``
- ``<esc>`` ``<enter>`` ``<tab>`` ``<s-tab>`` ``<space>`` ``<bs>`` ``<del>``
``<ins>`` ``<home>`` ``<end>`` ``<up>`` ``<down>`` ``<left>`` ``<right>``
``<pgup>`` ``<pgdn>`` ``<f1>`` … ``<f12>``
- 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:
return data
@@ -75,18 +234,20 @@ def decode_write_data(data: str) -> str:
i = 0
n = len(data)
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:]
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)
if m_hex is not None:
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:
"""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`.
"""
try:
+20 -8
View File
@@ -3,27 +3,39 @@ from __future__ import annotations
from plyngent.agent import ToolTag, tool
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
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
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
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``
- ``\\\\xHH`` byte, ``\\\\uHHHH`` Unicode code point
- ``ctrl+c`` / ``ctrl+x`` ... (case-insensitive)
- ``key=esc|enter|tab|up|down|left|right``
- ``<esc>`` ``<enter>`` ``<tab>`` ``<s-tab>`` ``<space>`` ``<bs>`` ``<del>``
``<ins>`` ``<home>`` ``<end>`` ``<up>`` ``<down>`` ``<left>`` ``<right>``
``<pgup>`` ``<pgdn>`` ``<f1>`` … ``<f12>``
- 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:
raw = decode_write_data(data)
return write_pty_payload(session_id, raw)
except KeySyntaxError as exc:
return f"error: {exc}"
except WorkspaceError as exc:
return f"error: {exc}"
except OSError as exc:
+50
View File
@@ -122,6 +122,14 @@ class WorkspacePolicy:
static_read: list[Path] = field(default_factory=list)
# ``[trash] enabled``: deletes go to the system trash unless cleared.
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:
@@ -200,6 +208,40 @@ def get_trash_enabled() -> bool:
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:
"""Set denied command basenames (None restores defaults)."""
policy = active_workspace_policy()
@@ -403,9 +445,17 @@ def resolve_path(path: str | Path, *, required: AccessMode = AccessMode.WRITE) -
candidate = root / candidate
resolved = candidate.expanduser().resolve()
if not _under_any_root(resolved, instance, policy) and not _path_grant_covers(resolved, required):
# A refused *write* is often a scratch file the model meant to keep in
# the workspace: name that way out too, next to asking the human.
scratch = (
"; if the file is only scratch, call new_temporary_workspace and write under the absolute path it returns"
if required is AccessMode.WRITE
else ""
)
msg = (
f"path escapes workspace root ({root}): {path}; "
f"call request_directory_access with mode={required.name.lower()!r} to request access outside the workspace"
f"{scratch}"
)
raise WorkspaceError(msg)
# Normalize separators so denylist entries like ``/secrets/`` match on Windows.
+124 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, overload
import asyncio
from typing import TYPE_CHECKING, Literal, cast, overload
import pytest
from msgspec import UNSET
@@ -928,6 +929,128 @@ async def test_retry_keeps_committed_tools_after_second_round_fails() -> None:
await store.close()
async def test_retry_keeps_lineno_reads_across_attempt_tasks(tmp_path: object) -> None:
"""A numbered read before a failed round still enables edits after retry.
Each turn attempt runs in its own task (the host wraps turns in
``asyncio.create_task``), so the retry attempt's context does not carry the
first attempt's ContextVar binding. Without ``retry`` re-binding the lineno
tracker, ``read_file(with_lineno=true)`` silently records nothing and
``edit_lineno`` rejects every line edit — even after re-reading the file as
its own error message instructs.
"""
from pathlib import Path
from plyngent.tools.context import InstanceState
from plyngent.tools.file import edit_lineno, read_file
from plyngent.tools.workspace import (
clear_workspace_allowlist,
clear_workspace_root,
set_workspace_root,
)
root = cast("Path", tmp_path).resolve()
(root / "f.txt").write_text("alpha\nbravo\ncharlie\n", encoding="utf-8")
instance = InstanceState(workspace_root=root)
from plyngent.tools.context import bind_instance
with bind_instance(instance):
clear_workspace_allowlist()
_ = set_workspace_root(root)
try:
registry = ToolRegistry([read_file, edit_lineno], instance_state=instance)
class ReadThenBoom:
"""Round 1 reads with line numbers; round 2's model call fails."""
n: int
def __init__(self) -> None:
self.n = 0
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
) -> ChatCompletionResponse: ...
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[True]
) -> AsyncIterator[ChatCompletionChunk]: ...
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
del stream
self.n += 1
if self.n == 1:
return _response(
AssistantChatMessage(
content="",
tool_calls=[
AssistantFunctionToolCall(
id="c1",
function=AssistantFunctionTool(
name="read_file",
arguments='{"path": "f.txt", "with_lineno": true}',
),
)
],
)
)
if self.n == 2:
msg = "api down after read"
raise RuntimeError(msg)
if self.n == 3:
edit_args = '{"path": "f.txt", "start_line": 2, "end_line": 2, "new_content": "BRAVO\\n"}'
return _response(
AssistantChatMessage(
content="",
tool_calls=[
AssistantFunctionToolCall(
id="c2",
function=AssistantFunctionTool(
name="edit_lineno",
arguments=edit_args,
),
)
],
)
)
return _response(AssistantChatMessage(content="done"))
client = ReadThenBoom()
agent = ChatAgent(
client,
model="m",
tools=registry,
stream=False,
)
async def run_attempt() -> list[object]:
return [e async for e in agent.run("edit line 2")]
# First attempt in its own task (mirrors run_cancellable): commits
# the read, then the next model round fails.
with pytest.raises(RuntimeError, match="api down after read"):
await asyncio.create_task(run_attempt())
assert isinstance(agent.messages[-1], ToolChatMessage)
# Retry in a *fresh* task: its context copy has no tracker bound.
async def run_retry() -> list[object]:
return [e async for e in agent.retry()]
events = await asyncio.create_task(run_retry())
results = [e for e in events if isinstance(e, ToolResultEvent)]
assert results, "retry must run the edit_lineno call"
assert "replaced lines 2-2" in results[0].message.content
assert (root / "f.txt").read_text(encoding="utf-8") == "alpha\nBRAVO\ncharlie\n"
assert agent.pending_retry_text is None
finally:
clear_workspace_allowlist()
clear_workspace_root()
async def test_chat_agent_system_prompt_prepended() -> None:
from plyngent.lmproto.openai_compatible.model import SystemChatMessage
+34
View File
@@ -16,6 +16,7 @@ from plyngent.lmproto.openai_compatible.model import (
ToolChatMessage,
UserChatMessage,
)
from plyngent.tools.context import InstanceState
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()])) == (
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"
await render_events(_aiter([_pretty_call("write_pty", '{"session_id": 3, "data": "ls -la"}'), _result(status)]))
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(
_aiter(
[
_pretty_call("write_pty_keys", '{"session_id": 3, "data": "ctrl+c"}'),
_result("session_id=3\nalive=true\nexit_code=\nwrote=1"),
_pretty_call("write_pty_keys", '{"session_id": 3, "data": "<c-c><enter>"}'),
_result("session_id=3\nalive=true\nexit_code=\nwrote=2"),
]
)
)
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:
+185
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import contextlib
import sys
from typing import TYPE_CHECKING
import pytest
@@ -18,9 +19,11 @@ from plyngent.cli.limits import (
)
from plyngent.prompting import (
NonInteractiveBackend,
PromptWait,
get_prompt_backend,
get_prompt_output_hook,
get_prompt_wait_hook,
set_prompt_wait_hook,
temporary_backend,
)
from plyngent.tools.process.pty_session import PtyManager
@@ -253,6 +256,60 @@ def test_tool_confirm_denied_by_the_notification() -> None:
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:
from plyngent.agent import ToolConfirmRequest
from plyngent.cli.limits import _prompt_confirm_batch_sync
@@ -277,6 +334,134 @@ def test_batch_confirm_denied_by_the_notification() -> None:
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_limit_prompt_still_names_its_answers_at_the_terminal(capsys: pytest.CaptureFixture[str]) -> None:
"""Buttons elsewhere do not say what typing here would do."""
from plyngent.cli import limits
with temporary_backend(ScriptedBackend([])), _answered("y"):
assert limits.prompt_continue_limit("hit a wall") is True
assert "[Y/n/yyy]" in capsys.readouterr().err
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_a_tool_confirm_names_the_object_it_is_about() -> None:
"""A notification body carries the program or file, never the argument."""
seen: list[PromptWait] = []
@contextlib.contextmanager
def hook(wait: PromptWait) -> Generator[None]:
seen.append(wait)
yield None
previous = get_prompt_wait_hook()
set_prompt_wait_hook(hook)
try:
with temporary_backend(ScriptedBackend([], confirms=[True, True])):
_ = prompt_confirm_tool("run_argv", {"argv": ["/usr/bin/rm", "-rf", "build"]}, "command 'rm'")
_ = prompt_confirm_tool("write_file", {"path": "notes/todo.md", "content": "x"}, "overwrite")
finally:
set_prompt_wait_hook(previous)
assert [wait.facts for wait in seen] == [("program: rm",), ("target: todo.md",)]
def test_a_timed_prompt_says_when_silence_answers() -> None:
"""Timeout is a deny, so the notification must not hide the clock."""
from pathlib import Path
from plyngent.cli import limits
from plyngent.tools.workspace import AccessMode
seen: list[PromptWait] = []
@contextlib.contextmanager
def hook(wait: PromptWait) -> Generator[None]:
seen.append(wait)
yield None
previous = get_prompt_wait_hook()
set_prompt_wait_hook(hook)
try:
with temporary_backend(ScriptedBackend(["y"], confirms=[True])):
_ = limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0)
finally:
set_prompt_wait_hook(previous)
assert seen[0].expires_in == 30.0
assert seen[0].facts == ("kind: directory",)
def test_every_confirm_announces_itself() -> None:
"""Each blocking confirm reports its kind, so a host can pick a surface."""
from pathlib import Path
+324 -23
View File
@@ -1,14 +1,17 @@
from __future__ import annotations
import sys
import time
from typing import TYPE_CHECKING
import types
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar
import pytest
from plyngent.cli.notify import (
ALLOW,
DENY,
REVIEW,
DesktopNotifierChannel,
DesktopRequest,
WaitNotifier,
detect_multiplexer,
@@ -95,6 +98,7 @@ def _notifier(
desktop: _FakeDesktop | None = None,
grace_seconds: float = 60.0,
min_interval_seconds: float = 0.0,
workspace: str | None = None,
**kwargs: object,
) -> tuple[WaitNotifier, list[str]]:
"""A notifier with a captured terminal writer and a fake desktop channel."""
@@ -109,6 +113,7 @@ def _notifier(
desktop=desktop,
write=written.append,
env={"TERM_PROGRAM": "WezTerm"},
workspace=workspace,
)
return notifier, written
@@ -150,6 +155,12 @@ def test_terminal_sequence_osc777_and_osc9() -> None:
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:
seq = terminal_sequence("osc777", "T", "M", multiplexer="tmux")
assert seq.startswith("\x1bPtmux;\x1b")
@@ -205,7 +216,9 @@ def test_desktop_channel_can_be_pinned() -> None:
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert len(desktop.requests) == 1
assert written == []
# No terminal notification (it was pinned), but the bell still says which
# window holds the prompt.
assert written == ["\a"]
def test_terminal_fallback_when_the_desktop_refuses() -> None:
@@ -246,34 +259,186 @@ def test_disabled_notifications_send_nothing() -> None:
# --------------------------------------------------------------------------- #
def test_a_confirmation_offers_three_buttons() -> None:
def test_a_confirmation_offers_allow_and_deny() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert handle.supports_actions is True
request = desktop.requests[0]
assert [key for key, _ in request.buttons] == [ALLOW, DENY, REVIEW]
assert [key for key, _ in request.buttons] == [ALLOW, DENY]
assert request.reply_field is False
def test_an_ask_offers_a_reply_field_instead_of_buttons() -> None:
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_a_question_offers_a_reply_field_without_the_answers() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("ask", "a question is waiting", body="Which branch?")) as handle:
handle._notify()
request = desktop.requests[0]
assert request.reply_field is True
assert request.buttons == ()
def test_a_limit_prompt_carries_no_answers() -> None:
def test_a_question_carries_the_question_in_its_body() -> None:
"""A question *is* what is asked, so the title cannot be the whole story."""
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
question = "Which branch should the release be cut from?"
with notifier(PromptWait("ask", "a question is waiting", body=question)) as handle:
handle._notify()
assert desktop.requests[0].message == question
def test_the_body_names_the_object_and_when_not_answering_answers() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
wait = PromptWait("policy", "allow command 'rm'?", facts=("program: rm",), expires_in=30.0)
with notifier(wait) as handle:
handle._notify()
# A popup wraps, so the desktop body keeps its lines apart.
assert desktop.requests[0].message.split("\n") == ["program: rm", "no answer in 30s = deny"]
def test_the_terminal_body_stays_on_one_line() -> None:
notifier, _ = _notifier()
wait = PromptWait("confirm", "Allow?", facts=("program: rm",), expires_in=30.0)
assert notifier._message(wait) == "program: rm no answer in 30s = deny"
def test_a_long_reason_goes_before_the_deadline_does() -> None:
"""The deadline is short and is the one line a human must not miss."""
notifier, _ = _notifier(include_reason=True)
wait = PromptWait("confirm", "Allow?", "x" * 400, facts=("program: rm",), expires_in=30.0)
message = notifier._message(wait)
assert message.endswith("no answer in 30s = deny")
assert len(message) <= 200
def test_a_click_on_the_notification_leaves_the_prompt_waiting() -> None:
"""The body click is the default action: the notification goes, the wait stays."""
desktop = _FakeDesktop()
notifier, written = _notifier(desktop=desktop)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
request = desktop.requests[0]
assert request.on_click is not None
request.on_click()
assert handle.take() is None
assert desktop.cleared == ["id-1"]
# One bell, rung when the notification went out — not again for the click.
assert written == ["\a"]
def test_the_bell_rings_when_the_popup_goes_out() -> None:
"""A popup can sit on another virtual desktop; the bell says where to look."""
desktop = _FakeDesktop()
notifier, written = _notifier(desktop=desktop)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert written == ["\a"]
assert desktop.requests[0].buttons == ((ALLOW, "Allow once"), (DENY, "Deny"))
def test_the_body_names_the_workspace_under_a_tilde(monkeypatch: pytest.MonkeyPatch) -> None:
"""Two chats look alike; which directory waits is what tells them apart."""
monkeypatch.setenv("HOME", "/home/tester")
notifier, _ = _notifier(workspace="/home/tester/work/plyngent")
assert notifier._message(PromptWait("confirm", "Allow?")) == "~/work/plyngent"
notifier.set_workspace("/srv/other")
assert notifier._message(PromptWait("confirm", "Allow?")) == "/srv/other"
def test_a_rebound_workspace_moves_the_label(monkeypatch: pytest.MonkeyPatch) -> None:
"""A resumed session runs in its own directory; the label follows it."""
monkeypatch.setenv("HOME", "/home/tester")
from plyngent.cli.state import ReplState
notifier = install_notifier(NotifyConfig(), workspace="/home/tester/one")
assert notifier is not None
ReplState._retarget_notifier(Path("/home/tester/two"))
assert notifier._message(PromptWait("confirm", "Allow?")) == "~/two"
def test_a_timed_prompt_lets_the_popup_expire_with_it() -> None:
"""The popup must not outlive the answer window it announces."""
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("policy", "allow command 'rm'?", expires_in=30.0)) as handle:
handle._notify()
assert desktop.requests[0].timeout == 30
def test_a_prompt_without_a_deadline_keeps_its_popup() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert desktop.requests[0].timeout is None
def test_a_reply_is_an_answer_whatever_it_spells() -> None:
"""A reply field's text is the answer, even when it reads like a button key."""
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("ask", "a question is waiting", body="What now?")) as handle:
handle._notify()
request = desktop.requests[0]
assert request.on_reply is not None
request.on_reply("review")
assert handle.take() == "review"
def test_a_secret_prompt_is_never_announced() -> None:
"""The value would have to come back through a surface that outlives it."""
desktop = _FakeDesktop()
notifier, written = _notifier(desktop=desktop, grace_seconds=0.0)
with notifier(PromptWait("secret", "API key?")) as handle:
assert notifier.may_notify(handle) is False
handle._notify()
assert desktop.requests == []
assert written == []
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()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("limit", "Round limit reached")) as handle:
assert handle.supports_actions is True
handle._notify()
assert handle.supports_actions is False
assert desktop.requests[0].buttons == ()
answer = desktop.requests[0].on_answer
assert answer is not None
answer(ALLOW)
assert handle.take() == ALLOW
assert desktop.requests[0].reply_field is False
@@ -318,18 +483,6 @@ def test_a_reply_reaches_the_prompt() -> None:
assert handle.take() == "feature/x"
def test_review_keeps_waiting_and_dismisses() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
request = desktop.requests[0]
assert request.on_answer is not None
request.on_answer(REVIEW)
assert handle.take() is None
assert desktop.cleared == ["id-1"]
def test_an_answer_after_the_prompt_is_ignored() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
@@ -351,6 +504,153 @@ def test_the_notification_is_dismissed_when_the_prompt_returns() -> None:
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
@pytest.mark.usefixtures("_stub_extra")
def test_the_desktop_call_carries_the_click_handler() -> None:
"""Clicking the body is the default action, so it must reach the prompt."""
channel = DesktopNotifierChannel()
clicked: list[str] = []
def _click() -> None:
clicked.append("click")
_ = channel.send(DesktopRequest(title="Allow?", message="", on_click=_click))
assert _RecordingNotifier.sent[-1]["on_clicked"] is _click
@pytest.mark.usefixtures("_stub_extra")
def test_the_popup_can_be_told_when_to_expire() -> None:
"""A prompt that gives up on its own takes its popup with it."""
channel = DesktopNotifierChannel()
_ = channel.send(DesktopRequest(title="Allow?", message="", timeout=30))
assert _RecordingNotifier.sent[-1]["timeout"] == 30
def test_a_prompt_offers_its_own_answers() -> None:
"""The prompt's ``choices`` become the buttons."""
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
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"),
)
# 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
# --------------------------------------------------------------------------- #
@@ -370,9 +670,10 @@ def test_install_notifier_can_be_disabled() -> None:
def test_reason_stays_out_unless_asked_for() -> None:
notifier, _ = _notifier()
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)
assert "rm -rf" in notifier._message(wait)
assert notifier._message(wait) == "argv: rm -rf /"
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(),
)
from platformdirs import user_data_path
global_db = user_data_path("plyngent") / "chat.db"
global_mtime = global_db.stat().st_mtime if global_db.exists() else None
# 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
# nothing was created there.
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()
result = runner.invoke(
@@ -162,8 +164,7 @@ access_key_or_token = "sk"
assert result.exit_code == EXIT_OK
assert "pong" in result.output
assert db_path.is_file()
if global_mtime is not None:
assert global_db.stat().st_mtime == global_mtime
assert not (unused_data_dir / "chat.db").exists()
def test_chat_help_mentions_prompt() -> None:
+22
View File
@@ -24,6 +24,8 @@ from plyngent.lmproto.openai_compatible.model import (
UserChatMessage,
)
from plyngent.memory import MemoryStore
from plyngent.tools import image_input_refusal
from plyngent.tools.context import bind_instance
if TYPE_CHECKING:
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]
assert "does not accept image input" 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
+64 -1
View File
@@ -267,6 +267,25 @@ def test_non_interactive_confirm_uses_default() -> None:
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:
"""A registered hook runs before echo/secho write; clearing it removes it.
@@ -337,6 +356,34 @@ def test_prompt_wait_without_hook_yields_none() -> None:
assert source is None
def test_prompt_wait_carries_what_the_caller_declares() -> None:
"""Everything a host needs to say what is asked and when silence answers."""
seen: list[PromptWait] = []
@contextlib.contextmanager
def hook(wait: PromptWait) -> Generator[None]:
seen.append(wait)
yield None
set_prompt_wait_hook(hook)
choices = (("y", "Continue"), ("n", "Stop"))
with prompt_wait(
"policy",
"allow command 'rm'?",
"argv: rm -rf x",
choices,
body="",
facts=("program: rm",),
expires_in=30.0,
):
pass
assert seen[0].body == ""
assert seen[0].detail == "argv: rm -rf x"
assert seen[0].choices == choices
assert seen[0].facts == ("program: rm",)
assert seen[0].expires_in == 30.0
def test_prompt_wait_hook_wraps_ask() -> None:
"""The CLI hook sees one PromptWait per prompt and is exited afterwards."""
seen: list[PromptWait] = []
@@ -355,10 +402,26 @@ def test_prompt_wait_hook_wraps_ask() -> None:
backend = ScriptedBackend(["hi"])
with temporary_backend(backend):
assert ask("Name?") == "hi"
assert [(wait.kind, wait.title) for wait in seen] == [("ask", "Name?")]
assert [(wait.kind, wait.title, wait.body) for wait in seen] == [("ask", "a question is waiting", "Name?")]
assert entered == ["in", "out"]
def test_a_choice_prompt_names_its_question_and_options() -> None:
"""The prompt a host shows carries the question and what can be picked."""
seen: list[PromptWait] = []
@contextlib.contextmanager
def hook(wait: PromptWait) -> Generator[None]:
seen.append(wait)
yield None
set_prompt_wait_hook(hook)
with temporary_backend(ScriptedBackend(["1"])):
assert choose("Pick one", ["main", "release", "next", "other", "fifth"]) == "main"
assert seen[0].body == "Pick one"
assert seen[0].facts == ("main", "release", "next", "other")
def test_prompt_wait_hook_reports_the_prompt_family() -> None:
"""Each entry point announces its own kind so a host can pick a surface."""
kinds: list[str] = []
+87 -1
View File
@@ -1,6 +1,6 @@
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:
@@ -132,6 +132,92 @@ def test_unwrap_empty_argv() -> None:
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:
assert is_interpreter("python")
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
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:
for argv in (
["env", "FOO=1", "python", "-c", "print(1)"],
+19 -1
View File
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, override
import pytest
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.net import fetch_local, grant_private_host
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,")
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:
"""In a read-only context fetch_local only allows GET; outside it POST works."""
del workspace
+84 -6
View File
@@ -4,6 +4,8 @@ import asyncio
import sys
from pathlib import Path
import pytest
from plyngent.tools.process import (
ask_into_pty,
close_pty,
@@ -514,13 +516,76 @@ def test_decode_write_data_escapes() -> None:
from plyngent.tools.process.pty_terminal import decode_write_data
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("key=esc") == "\x1b"
assert decode_write_data("key=enter") == "\r"
assert decode_write_data(r"a\nb") == "a\nb"
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:
@@ -567,7 +632,7 @@ async def test_write_pty_keys_ctrl_escape(workspace: object) -> None:
),
)
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
text = await call_async(read_pty, session_id, timeout=2.0)
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()
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:
"""Plain write_pty must not call the keys decoder."""
import inspect
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)
assert "decode_write_data" not in src
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 plyngent.agent import ToolOutput
from plyngent.tools.context import get_instance
from plyngent.tools.file.read import read_file
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"
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:
(workspace / "shot.png").write_bytes(PNG)
+65
View File
@@ -9,10 +9,12 @@ from plyngent.tools import (
check_command_allowed,
get_trash_enabled,
get_workspace_root,
image_input_refusal,
mode_covers,
parse_access_mode,
resolve_path,
set_command_denylist,
set_image_in,
set_path_denylist,
set_trash_enabled,
)
@@ -48,6 +50,21 @@ def test_escape_error_names_the_mode_to_request(workspace: object, required: Acc
_ = resolve_path("../outside", required=required)
def test_escape_error_offers_a_temporary_workspace_for_a_write(workspace: object) -> None:
"""A refused write is often scratch, which never needs a grant."""
del workspace
with pytest.raises(WorkspaceError, match="new_temporary_workspace"):
_ = resolve_path("../scratch.txt", required=AccessMode.WRITE)
@pytest.mark.parametrize("required", [AccessMode.READ, AccessMode.EXEC])
def test_escape_error_hides_the_scratch_hint_from_other_modes(workspace: object, required: AccessMode) -> None:
del workspace
with pytest.raises(WorkspaceError) as refusal:
_ = resolve_path("../scratch.txt", required=required)
assert "new_temporary_workspace" not in str(refusal.value)
def test_access_mode_parse_and_covers() -> None:
assert parse_access_mode(" Read ") is AccessMode.READ
assert parse_access_mode("EXEC") is AccessMode.EXEC
@@ -67,6 +84,34 @@ def test_trash_switch(workspace: object) -> None:
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:
del workspace
outside = tmp_path_factory.mktemp("granted")
@@ -282,6 +327,26 @@ def test_command_denylist_reaches_through_wrappers(workspace: object) -> None:
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:
del workspace
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 = ...
TIMEOUT = ...
class Urgency(Enum):
Critical = ...
Normal = ...
Low = ...
class Button:
def __init__(
self,
@@ -50,7 +55,7 @@ class DesktopNotifier:
self,
title: str,
message: str,
urgency: Any = ...,
urgency: Urgency = ...,
icon: Any = ...,
buttons: Sequence[Button] = ...,
reply_field: ReplyField | None = None,