64 Commits

Author SHA1 Message Date
NCBM b897bac52b release: plyngent 0.15.0 2026-10-29 23:50:36 +08:00
NCBM 725caaada4 deps: refresh the lock (platformdirs 4.14.0, qh3 2.1.0, pytest 9.2.0, pywinpty 3.1.2, winrt 4) 2026-10-29 23:50:20 +08:00
NCBM dd678d213b docs: describe how a refusal reads
Which four answers a denial names, and that a timeout is never reported as a
decision.
2026-10-29 23:44:02 +08:00
NCBM 4bab780603 test: cover declined, timed out, cancelled and absent prompts
The four kinds and the words each produces, at the registry level and through
the four policy gates: a timeout names its deadline, a decline carries the
comment, and neither is reported as the other. The existing denial assertions
now expect the specific clause.
2026-10-29 23:43:49 +08:00
NCBM 8cbb98602e cli: report each prompt's own denial
The prompts knew the difference already — one branch printed "timed out",
another fell through to a plain no — and then threw it away by returning
False/None.

Each returns a `Denial` now: the timed policies name their deadline, a cancel
is its own answer, and a non-interactive run says nobody could be asked. The
batch decision and the skill write decision carry it too.
2026-10-29 23:43:20 +08:00
NCBM 4c67f6bb04 tools: carry the denial reason into the tool message
The four policy gates phrased their refusal themselves, and all four said
"declined or timed out". They read the `Denial` their prompt returned instead:
the command denylist, the private-host fetch policy, the skill write and the
directory-access request each name the answer they got.

`get_fetch_policy_confirm_hook` no longer coerces the answer to `bool`, which
would have reported a timeout as an allow.
2026-10-29 23:43:04 +08:00
NCBM 79ccf593f6 core/agent: name why a confirmation answered no
A refused tool call read "denied by user confirm", whether the human had said
no or the prompt had timed out — the model was told a decision it never got.

`Denial` (`agent/tools.py`) carries the kind — declined / timeout / cancelled /
no prompt — plus the deadline and the human's comment, and `Denial.clause()`
spells it once for every message that needs it. The soft gate and the batch
decision pass it through, so a timeout says its deadline and a missing hook
says nobody was asked.
2026-10-29 23:42:29 +08:00
NCBM 329fec8b3e docs: say YOLO never answers a directory-access request
Also stop crediting `yolo_allow` to `--yes`: the store is what a host fills
when it approves without keeping the grant.
2026-10-29 23:19:54 +08:00
NCBM 2614f8ca99 test: cover YOLO not answering the directory-access prompt
The answer is a human one under `--yes` (session scope, not a process grant),
and a run with no terminal to ask denies.
2026-10-29 23:19:46 +08:00
NCBM 7b3f55ea65 cli: leave a directory-access request to the human under YOLO
The access hook short-circuited on YOLO mode and answered its own question
with a process-only grant, so `--yes` (and `/yolo on`) reached any path outside
the workspace without asking. That grant is a filesystem capability, not one
risky call: the command denylist and a skill write are asked either way, and so
is this one now.

`yolo_allow` stays as the process-scoped store a host fills when it approves
without persisting; nothing auto-approves it any more. A non-interactive run
denies, like the other policy prompts.
2026-10-29 23:19:38 +08:00
NCBM b75ee88764 docs: describe the approval line
What a confirmed result leads with, which answers each policy path records,
and what never gets a line.
2026-10-29 18:30:00 +08:00
NCBM 6d7c239718 test: cover the approval line and the policy records
Registry cases (none / once / round / grant / YOLO / denial / error / handler
record / closest answer wins), the wire cap keeping the line, the three policy
paths that record their answer, and a pretty detail that ignores the header.
The existing confirm tests now assert the line they gain.
2026-10-29 18:29:28 +08:00
NCBM ed8013d6ce cli/display: read past the approval line when rendering a detail
The line is for the model, and the human already saw the prompt. A detail that
parses the result (write_file's char count) must not see a header in front of
it; the verbose `[tool ok]` view keeps the whole text.
2026-10-29 18:29:20 +08:00
NCBM 05166f2f76 tools: record the policy answers that let a call through
The command denylist, the private-host fetch policy and the skill write grant
asked the human and then said nothing about it in the result.

Each now records the answer for the running call: `user (process)` for this
call's override and `grant (process)` when an earlier one already covers the
program or host, `user (once|session)` / `grant (session)` / `config (policy)`
for a skill write. `request_directory_access` is unchanged: its own text already
names the level and scope it granted.
2026-10-29 18:28:48 +08:00
NCBM f9ec812275 core/agent: lead a confirmed tool result with how it was approved
A tool result said nothing about the answer that let the call run: the human
approved a delete, the model saw only "deleted x", and a resumed transcript
could not tell a YOLO auto-approval from a prompt.

The soft gate now returns an `Approval` (`user` / `grant` / `yolo` / `config`
with the scope that answer reaches) and `execute_output` leads the result with
`[approval] user (once)`. A handler that prompts on its own records its answer
through `tools/context.py` (`collect_approvals` around one call), and the one
line printed is the answer closest to a human. A call that needed no
confirmation, a denial and an `error:` result carry no line.
2026-10-29 18:28:13 +08:00
NCBM 1c2587db82 docs: say what the storage ceiling leaves readable
The comments claimed a result above the ceiling has no document, which is not
what the code does: it has one, and the dropped tail is what that document
cannot serve.
2026-10-29 15:17:33 +08:00
NCBM 828c2610cb test: cover the ceiling notice at the head and in the document marker
A cut body must lead with its notice, and a wire-capped cut body must say so
in the marker; a body the ceiling left whole must not.
2026-10-29 15:17:25 +08:00
NCBM 7abc7c2cb8 core/agent: lead a ceiling-cut tool result with its dropped-tail notice
The storage ceiling's notice trailed the head it was cut from, and the wire
cap sends the head: the only way to learn that a result had been cut was to
fetch a 2 MB body back through its document, which reads to the very end.

The notice now leads, says the tail is not readable, and `cap_tool_message`
names the same fact in the document marker, so neither the head nor the marker
presents a cut body as the whole output.
2026-10-29 15:17:12 +08:00
NCBM 7d29498dfa docs: describe the document model for tool results
AGENTS.md, README, and /status now describe the request-prep cap and the
document tools (`read_document` / `search_document`). /status also shows the
storage ceiling.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-10-29 14:24:35 +08:00
NCBM 140268f983 tools/truncate_token: scope the token cursor to file reads and mentions
Tool results no longer use a truncate token, so the shared `truncate_generic`
wrapper goes. `read_file` keeps its file token, and a user `@path` mention
keeps the memory token (its inlined text has no re-readable source).

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-10-29 14:16:40 +08:00
NCBM 7d6e367143 tools: keep tool output whole and let the request-prep pass cap it
run_argv, run_argv_batch, fetch, fetch_local, and the vcs tools now return
their output whole, so the request-prep cap trims it on the wire and names
its document. A fetched page or a long log reads on from the tool message,
with no re-fetch and no re-run.

The run_argv_batch run budget stays as an execution guard: it stops a
runaway batch from spawning further huge-output steps.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-10-29 14:13:36 +08:00
NCBM 63109ef9f2 core/agent: cap tool results in the request-prep pass
A tool result now stays whole in its message (up to a storage ceiling) and
is trimmed on the request copy by the request-prep pass, where soft compact
already runs. The trimmed copy names the document that holds the rest, so
every surface (chat-completions, Responses, Messages) shares one rule.

New [agent] hard_max_tool_result_chars bounds the stored body; the wire cap
stays [agent] max_tool_result_chars.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-10-29 10:04:32 +08:00
NCBM dfaaad563c tools/documents: add document tools over capped tool results
A tool result stays whole in its own tool message; a request-prep marker
names a document_id (the message's tool_call_id). read_document,
search_document, document_info, and list_documents resolve that id back to
the message body, so reading and searching a capped result needs no re-run.

The store binds on the host instance and reads the agent's live history,
which carries whole results and re-reads whatever a resume reloaded.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-10-29 02:36:36 +08:00
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
71 changed files with 3452 additions and 802 deletions
+10 -10
View File
@@ -86,38 +86,38 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
- **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`.
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; request-prep tool-result cap (each result stored whole, trimmed on the request copy with a `document_id=...` marker); soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`; resolved `reasoning_effort` on every request (see Config). A round the provider cut off at the output limit is kept, not failed: the answer and its thinking blocks stay in `messages`, the round's tool calls are dropped (a half-written call must not run — the model re-issues it), and up to `max_truncation_continues` (2) further rounds carry on from a truncation notice; 0 spends the turn as unfinished instead, for `/retry`.
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `provider_tools`; `retry()` continues incomplete turns (user-only, after committed tools — does not re-run those tools — or after a kept truncated answer) and `pending_retry_text` reads a truncation notice as unfinished work.
- **`/compact`**: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first. The default summarizer system prompt states that tool calls are unavailable (a `compact_system_prompt` override replaces that default wholesale).
- Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **truncated** (`kept_chars`/`continued`), **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
- Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`.
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `allow_paths` (static out-of-workspace pre-allow `path → read|write|exec`; never prompts), `auto_continue_limits` (auto-raise tool/PTY limits without prompting; default false), `reasoning_effort` / `thinking_budget_tokens` (thinking strength; see Config), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `allow_paths` (static out-of-workspace pre-allow `path → read|write|exec`; never prompts), `auto_continue_limits` (auto-raise tool/PTY limits without prompting; default false), `reasoning_effort` / `thinking_budget_tokens` (thinking strength; see Config), `max_tool_result_chars` (wire cap; the request-prep pass trims a longer tool result to its head plus a `document_id=...` marker), `hard_max_tool_result_chars` (default 2,000,000: storage ceiling for one result kept whole in its message; a result above it keeps its head behind a leading `[Stored at the …-char ceiling: …]` notice and its document cannot serve the dropped tail), `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens).
### Tools (`tools/`)
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.
- **`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` (process only: an answer a host keeps without persisting — YOLO/`--yes` never fills it, an out-of-workspace grant is asked either way). `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 a truncated `read_file` slice or `@path` mention via its `truncate_token=...`), `read_document` / `search_document` (read a capped tool result by its `document_id`: a line window with optional line numbers, or a per-line regex), `document_info` / `list_documents` (describe or list the session's documents).
- **`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` — 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.
- 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 and a directory-access request is asked either way; `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. Every allow says so in the result itself: the tool text leads with `[approval] user (once)`, `user (round)` (batch), `grant (session)` (a TRUSTABLE grant, so nobody was asked this time) or `yolo (auto)`, and a handler that prompts on its own records it with `tools.context.record_approval` — `workspace.check_command_allowed` (`user`/`grant (process)`), `tools/net/grants.py` (`user`/`grant (process)`) and the skill write path (`user (once|session)`, `grant (session)`, `config (policy)`). One line per call (`Approval` + `most_specific_approval`: the answer closest to a human wins), none for a call that needed no confirmation or ended in `error:`, and it leads so a wire-capped result and `read_document` keep it. A refusal names which no it was (`Denial` / `DenialKind`): `user declined` (with the comment), `no answer within 30s — denied by default` (the prompt's deadline), `prompt cancelled` (Ctrl+C / EOF), or `no confirm hook or non-interactive (denied)` — a timeout is never reported as a decision, and every timed prompt (`workspace.check_command_allowed`, `tools/net/grants.py`, `tools/skills.py`, `tools/access.py`) carries the reason it got into the tool message.
- **`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`).
- **`net` / `fetch_local` and `fetch`**: `fetch_local` is the raw HTTP client (GET/POST/PUT/DELETE) via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (`asyncio` `getaddrinfo`). Private/loopback hosts need instance policy grant (CLI timed confirm; **not** YOLO). Public cleartext HTTP and mutating methods soft-confirm (`YOLO`/`TRUSTABLE`). HTTPS→HTTP redirects blocked unless `allow_http_downgrade`. `user_agent` tool arg (or headers) is never replaced by the default when provided. Caps: body bytes/chars, request body size. `fetch` is the remote read-only counterpart, built by the CLI host only for a DeepSeek provider: `POST /v1/tools/fetch` (`lmproto/deepseek/webtools/`) returns a title and Markdown content, billed per successful request (5 QPS, plus a per-domain limit). It does **not** run the local SSRF check — the request leaves from DeepSeek's network, so local/LAN targets are unreachable there (the endpoint answers 403/404) — and never falls back to `fetch_local` on its own. Its body remainder also lives in the memory store, so `get_truncated` resumes without another charge. Both are `READ_ONLY`; `fetch_local` enforces GET-only in a read-only context.
- **`net` / `fetch_local` and `fetch`**: `fetch_local` is the raw HTTP client (GET/POST/PUT/DELETE) via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (`asyncio` `getaddrinfo`). Private/loopback hosts need instance policy grant (CLI timed confirm; **not** YOLO). Public cleartext HTTP and mutating methods soft-confirm (`YOLO`/`TRUSTABLE`). HTTPS→HTTP redirects blocked unless `allow_http_downgrade`. `user_agent` tool arg (or headers) is never replaced by the default when provided. Caps: body bytes, request body size. `fetch` is the remote read-only counterpart, built by the CLI host only for a DeepSeek provider: `POST /v1/tools/fetch` (`lmproto/deepseek/webtools/`) returns a title and Markdown content, billed per successful request (5 QPS, plus a per-domain limit). It does **not** run the local SSRF check — the request leaves from DeepSeek's network, so local/LAN targets are unreachable there (the endpoint answers 403/404) — and never falls back to `fetch_local` on its own. Its body is stored whole in the tool message, so a trimmed `document_id` reads the rest with no new request and no new charge. Both are `READ_ONLY`; `fetch_local` enforces GET-only in a read-only context.
- **`net` / `web_search`** (`tools/net/search.py`, `tools/net/bing.py`): the search a DeepSeek provider cannot host, as a local tool. `build_web_search_tool(backends)` closes over the sources named by `provider.search_sources` and asks them **in fallback order** (`search_in_order`): `DeepseekSearchBackend` (one `POST /v1/tools/search` through `lmproto/deepseek/webtools/`, no model round — `deepseek_api_base_url` drops the convention's `/anthropic` and any `/v1` before the client appends its path; 408/429/5xx are retried 0.5s/1.0s, 402 and other 4xx are not) and/or `BingSearchBackend` (Bing's RSS results, snippets included, fetched through the same client as `fetch_local`; opt in with `search_sources = ["deepseek", "bing"]` — the default is `["deepseek"]`). Hits are `SearchHit` from `lmproto/search.py` (DeepSeek's `snippet`, else its `summary` cut to 200 chars); the handler returns one `title — url` entry per hit (plus the snippet when the source has one), and a source that raises or finds nothing is skipped and reported only if every source fails. Registered by the CLI host only when `config.routing.local_web_search` says so: a DeepSeek provider with a non-empty `search_sources`, unless the Anthropic convention already hosts the search (`convention = "anthropic"` **and** `provider_tools` ask for `web_search`) — so `provider_tools = []` brings the local tool back on that surface too. `provider_tools` owns the hosted tool, `search_sources` owns this one. Tags LOCAL|READ_ONLY.
- **`mcp`** (`tools/mcp.py`): register one server's advertised tools into the catalog as namespaced `mcp__<server>__<tool>` definitions with `ToolSource(kind="mcp", plugin_id=server)`; tags LOCAL (+ READ_ONLY when the server config sets `read_only`) — never YOLO/TRUSTABLE. `register_mcp_tools` purges prior MCP entries then re-registers every connected server (hosts call it on tool-registry rebuild); connections must already be started by `McpManager`.
- **`skills`** (`tools/skills.py`): discovery, reading, and writing of skill directories (`plyngent.skills` owns roots, the `SKILL.md` frontmatter subset, the uncached store, and the inside-the-skill path rule). `skill_list` (names, source root, directory, description, shadowed copies, frontmatter notes), `skill_read` (a `SKILL.md` by name, or one bundled file — reuses `read_file` for ranges/line numbers/`truncate_token`), `skill_search` (regex over every visible skill via `regex_files`, hits rebased on `<skill>/<file>`), `skill_create` (new `SKILL.md` + optional `files`), `skill_edit` (replace / `append` / rewrite one file; a rewritten `SKILL.md` is re-parsed and notes a lost description). Reads need no grant (the host publishes the roots as `WorkspacePolicy.static_read`, so `read_file`/`regex_files` see them while writes stay denied); writes go through **`SkillWritePolicy`** on the instance — denylist first, then `SessionState.skill_write_roots` (a `session` answer), then `[skills].allow_write`, then the host's confirm hook (CLI `prompt_skill_write_confirm`: `o`nce/`s`ession/`n`o, timed, default deny, **not** skipped by YOLO); a host with no hook denies. Grants are per **skill directory**; `[skills].enabled = false` drops the tools from the registry (`SKILL_TOOL_NAMES`).
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + skills + net (`fetch_local`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `view_image`, `get_truncated`, `listdir`, `glob_paths`, `regex_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `skill_list`/`skill_read`/`skill_search`, `fetch`, `fetch_local`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch_local` accept GET only.
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + skills + net (`fetch_local`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `view_image`, `get_truncated`, `read_document`/`search_document`/`document_info`/`list_documents`, `listdir`, `glob_paths`, `regex_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `skill_list`/`skill_read`/`skill_search`, `fetch`, `fetch_local`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch_local` accept GET only.
### Prompting (`prompting.py`)
Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable backend; non-TTY uses defaults or errors. CLI limit/confirm hooks and chat tools both use this. Async helpers serialize prompts (`run_prompt_async`).
Three process-wide hooks let a host take over a prompt: `set_prompt_output_hook` (break an open pretty line before the prompt draws), `configure_prompting(pause_factory=…)` (the SIGINT guard around an off-loop read), and `set_prompt_wait_hook` — a prompt that blocks announces itself through `prompt_wait(kind, title, detail)`, which yields the `PromptAnswerSource` the read may consult (`take()` returns `allow` / `deny` / reply text without blocking) or `None` for notify-only. Only the outermost prompt of a nested read is announced; with no hook installed nothing changes. 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`, 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/`)
+8 -6
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).
@@ -339,12 +339,14 @@ Ctrl+C cancels the in-flight turn, and during the auto-retry countdown it cancel
- **Workspace** = root for file/process/VCS tools (default cwd).
- **Session** = SQLite chat bound to a workspace path.
- Paths outside the workspace need a **directory-access grant**: the model calls `request_directory_access` (`read` / `write` / `exec`), the human approves at the requested level or another one (timed y/N, default deny; `--yes`/`/yolo` auto-approve as a process-only grant), and approval lasts for the session (`/grants` lists/revokes). Static pre-allow lives under `[agent].allow_paths`. The path denylist always wins, and grants gate path-resolving tools only — they are **not** a sandbox (`run_argv`/PTY reach the whole filesystem).
- Paths outside the workspace need a **directory-access grant**: the model calls `request_directory_access` (`read` / `write` / `exec`), the human approves at the requested level or another one (timed y/N, default deny — `--yes`/`/yolo` do **not** answer it, and a non-interactive run denies), and approval lasts for the session (`/grants` lists/revokes). Static pre-allow lives under `[agent].allow_paths`. The path denylist always wins, and grants gate path-resolving tools only — they are **not** a sandbox (`run_argv`/PTY reach the whole filesystem).
- Resuming a session from another directory prompts: keep session workspace, rebind to current, or abort.
## Tools (when enabled)
Default registry: file ops (including `tree` with a markdown bullet-list default, `flat` paths or classic `decorated` on demand; default noise-dir skips), `run_argv` / `run_argv_batch` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), HTTP `fetch_local` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO) plus `fetch` on DeepSeek providers (public pages as Markdown through the provider's Fetch API), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), `wait` (line prompt with timeout; Enter disturbs), `request_directory_access` (out-of-workspace access grants), `get_truncated` (resume any truncated result via its `truncate_token=...`), skills (`skill_list` / `skill_read` / `skill_search` / `skill_create` / `skill_edit`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
Default registry: file ops (including `tree` with a markdown bullet-list default, `flat` paths or classic `decorated` on demand; default noise-dir skips), `run_argv` / `run_argv_batch` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), HTTP `fetch_local` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO) plus `fetch` on DeepSeek providers (public pages as Markdown through the provider's Fetch API), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), `wait` (line prompt with timeout; Enter disturbs), `request_directory_access` (out-of-workspace access grants), `get_truncated` (resume a truncated file read or `@path` mention via its `truncate_token=...`), documents (`read_document` / `search_document` / `document_info` / `list_documents` — read or search a capped tool result by its `document_id`), skills (`skill_list` / `skill_read` / `skill_search` / `skill_create` / `skill_edit`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
A call that needed a confirmation says how it got one: its result leads with `[approval] user (once)` — or `user (round)` for a batch answer, `grant (session)` when an earlier answer already covers it, `yolo (auto)` under YOLO, and `user (process)` / `config (policy)` for the denylist, private-host and skill-write policies. A call that needed none is unchanged.
**Skills** are instruction directories shared with other harnesses: a folder with a `SKILL.md` (YAML frontmatter — `name`, `description`, … — plus a Markdown body) and optional bundled files (scripts, references). Discovery, roots, grants, and the authoring format are documented in [doc/skills.md](doc/skills.md); the short version is that a session-start notice points the model at them (`skill_list` / `skill_read` / `skill_search`) without listing anything, so what exists (and the bodies) is read on demand rather than injected, and writing a skill is a separate grant (a timed `once`/`session`/`no` prompt, or `[skills].allow_write`) that is **not** a directory-access grant and is never skipped by YOLO.
@@ -352,12 +354,12 @@ Safety defaults:
- Paths stay under the workspace unless granted (`request_directory_access` / `[agent].allow_paths`); the refusal names the `mode` that would cover the call; optional `path_denylist` substrings always apply (`tree` also skips denylisted children by default).
- Command basename denylist (e.g. dangerous shells/utilities).
- Destructive tools (delete/move/overwrite) can require confirm (`confirm_destructive`; default deny in non-TTY). Override for the session with `/yolo on|off|once` or startup `--yes` (path/command denylists still apply).
- Destructive tools (delete/move/overwrite) can require confirm (`confirm_destructive`; default deny in non-TTY). Override for the session with `/yolo on|off|once` or startup `--yes` (path/command denylists and a directory-access request still ask). A refusal says which no it was — you declined (with your optional comment), nobody answered within the prompt's deadline, the prompt was cancelled, or no prompt was available.
- `write_file` refuses to replace an existing file unless `overwrite=true`; the confirm asks about that replacement.
- 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.
+69 -13
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,25 +47,53 @@ 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. Typing at the terminal always wins; a readline-only convenience
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
The desktop channel needs the optional extra:
@@ -70,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`
+23 -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
@@ -100,6 +113,11 @@
# system_prompt = "" # persona off; keep default tool_directives unless also cleared
# tool_directives = "" # playbook off (set both "" to disable system entirely)
max_tool_result_chars = 32000
# Storage ceiling for one tool result kept whole in its message. The wire cap
# above trims a result on the request copy and names a document for the stored
# remainder (read_document / search_document); a result above this ceiling keeps
# its head and a leading notice saying how much was dropped.
# hard_max_tool_result_chars = 2000000
parallel_tools = true
confirm_destructive = true
path_denylist = ["/secrets/", ".ssh/", ".gnupg/"]
Generated
+218 -176
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"
@@ -499,13 +499,13 @@ files = [
[[package]]
name = "platformdirs"
version = "4.13.5"
version = "4.14.0"
requires_python = ">=3.11"
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
groups = ["default"]
files = [
{file = "platformdirs-4.13.5-py3-none-any.whl", hash = "sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e"},
{file = "platformdirs-4.13.5.tar.gz", hash = "sha256:05831adf87d392760f4b5c3a0a95579ab5a10b18b9b9bd43680dcb47544788a0"},
{file = "platformdirs-4.14.0-py3-none-any.whl", hash = "sha256:1dadfc59a8a5d7565b3d9270ae8fa71c065595352c4277b41c11ee6c222c4b91"},
{file = "platformdirs-4.14.0.tar.gz", hash = "sha256:83f009c1e8c5fc36869b5171755b291887bd2cade7965233cdfabd23d754720b"},
]
[[package]]
@@ -532,27 +532,27 @@ files = [
[[package]]
name = "pytest"
version = "9.1.1"
version = "9.2.0"
requires_python = ">=3.10"
summary = "pytest: simple powerful testing with Python"
groups = ["dev"]
dependencies = [
"colorama>=0.4; sys_platform == \"win32\"",
"exceptiongroup>=1; python_version < \"3.11\"",
"iniconfig>=1.0.1",
"packaging>=22",
"pluggy<2,>=1.5",
"pygments>=2.7.2",
"tomli>=1; python_version < \"3.11\"",
"iniconfig>=2",
"packaging>=24",
"pluggy<2,>=1.6",
"pygments>=2.15",
"tomli>=2; python_version < \"3.11\"",
]
files = [
{file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"},
{file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"},
{file = "pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588"},
{file = "pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b"},
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
version = "1.4.1"
requires_python = ">=3.10"
summary = "Pytest support for asyncio"
groups = ["dev"]
@@ -562,72 +562,89 @@ dependencies = [
"typing-extensions>=4.12; python_version < \"3.13\"",
]
files = [
{file = "pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1"},
{file = "pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42"},
{file = "pytest_asyncio-1.4.1-py3-none-any.whl", hash = "sha256:507afa78ec77c6661164ef1a2368bc7820383fd1a14b2a7f813de8b5a92949cc"},
{file = "pytest_asyncio-1.4.1.tar.gz", hash = "sha256:e8266eb094e5ee9ffdb2068c9836f33e209ea77a970eb846933d416b5602669b"},
]
[[package]]
name = "pywinpty"
version = "3.1.0"
version = "3.1.2"
requires_python = ">=3.10"
summary = "Pseudo terminal support for Windows from Python."
groups = ["default"]
marker = "sys_platform == \"win32\""
files = [
{file = "pywinpty-3.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:d01178e9391366bc3325b4c49068cfce95d10d416f6ade62140b274741bc11e0"},
{file = "pywinpty-3.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:44918ef8bc408fbf52f54db9b8c33370aee0a59cee76b071a4b4f41ad5ed89c8"},
{file = "pywinpty-3.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:08afc428ec8765f2b8b9a9954727e1dafbefcb5912acc65952007779449650a1"},
{file = "pywinpty-3.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:98f00e50639e9edb740eb6eedd6c0ab82959f9633029db59363d06973d00ac0d"},
{file = "pywinpty-3.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:188923081eea840c15f37c54a29ad6871b04fddf8fbf1c4acf317d0e27bd9cac"},
{file = "pywinpty-3.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:714e8aa138bc61dcfd26c139d8cb8e7b954ea5c09c98bb014ab9b1f5741e289c"},
{file = "pywinpty-3.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c08a2e5fa7a62f31b5965f0be1b92c67394cd5f75dc0a033d45fadc597f0640d"},
{file = "pywinpty-3.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:751d0c727d0e81a246aba722cf0dcb2155fd6bc7b82574b1432f81256157ec73"},
{file = "pywinpty-3.1.0.tar.gz", hash = "sha256:14595b68054d61f2f1e90cc2b30be47be201de200ae1851c433d8cded101bce0"},
{file = "pywinpty-3.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:980af688956fe78c87882682732e6eea45aa0b8d10a9628812e95878264d5204"},
{file = "pywinpty-3.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:81d83678211cc6f8cd2954e98a8a18d8e5de1e0666900a1df8da70f33572d34d"},
{file = "pywinpty-3.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fe0abcffd095642c78ba7c6abf4cc8129dce9568e97a1c9194eea23383d0de29"},
{file = "pywinpty-3.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:30235f396856fbc03c9bb181b4e27bf50f7b355e432e5731729063d920a09867"},
{file = "pywinpty-3.1.2-cp315-cp315-win_amd64.whl", hash = "sha256:b8c47556fbf19595898dd75f45763072ff1160b080eefabeae8cc43c5acbd720"},
{file = "pywinpty-3.1.2-cp315-cp315-win_arm64.whl", hash = "sha256:7af19f25e769f0c70f5920ce0964c508abdfe01e676cf05d5ed935848d8c782e"},
{file = "pywinpty-3.1.2-cp315-cp315t-win_amd64.whl", hash = "sha256:ed5c5ca288001590fbf1d5a52c6389f0f6ae7e4751bdc8b2f8f9c7249f266f9b"},
{file = "pywinpty-3.1.2-cp315-cp315t-win_arm64.whl", hash = "sha256:0a40537712279ed74d5884b1af9037add1ed4b2e2339e9152268b582abad3a57"},
{file = "pywinpty-3.1.2.tar.gz", hash = "sha256:03e2d2abe7c0df64db354233c83479b2b43d9fdd1badceea994259ca3819b203"},
]
[[package]]
name = "qh3"
version = "2.0.4"
version = "2.1.0"
requires_python = ">=3.7"
summary = "A lightweight and fast implementation of QUIC and HTTP/3"
groups = ["default"]
marker = "(platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\""
files = [
{file = "qh3-2.0.4-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a64bf88e94862d54a0406db92b4c51e86775e2fa5223618f92431f7ea0e668a1"},
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d9d22cdf5a643f3f3236b3e25462d33083f88d200425afd09958cc0d2c5ca54"},
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6f9d726d83942a2cb20ee127e995a0249dbea3ffe2b3772eb113acfdfd3874a1"},
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:968a94dcdc85219794d44fdce7b15ce6a90ca295f2574881b623ebe06850311c"},
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bcae368dc6b5449397f81208493361a5197df1f955d4883b164294915f83638b"},
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36f201b834d96759a241171a4cdc6ab7442a6c52260d1dcfb1879707b430c6d7"},
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f47b1f2c5fe8d70472a6134f374b6022ad6f709d6be56282ad4024d28905829"},
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:edc81973c3823533d75da5570e89214d87aded6205ac76cb9ae3bb9f25b5ffe6"},
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1964c6471d5e4c074a002bf6a95c1797576165c000d74448260426e10209bed0"},
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:690cf4bcd8c7baf075187d6197ab31cc933d2aa951311dc65729cb01dccc92dc"},
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:8449e99c275b04c866f13f24e6d656d609fe8875fedf5896201a93a60bbe1412"},
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:db25d8132c966ccebc294b8aeb197d2f48d14f772e675ff8282ab958009b801b"},
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:c03566def5374925a6270bbc1dafc8c8889b3ff3ecd14396979ea07fec9def26"},
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:19cd7751da3f4d581ea6f5f44510a2b30a19495daba4e632c4e96d8375856694"},
{file = "qh3-2.0.4-cp314-cp314t-win32.whl", hash = "sha256:c6414837e375fd06f4664b276fcc3e76b17fa2431f650f52f0e41c48489d7e81"},
{file = "qh3-2.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:1923830f3e5e236bcf024ea317625346e5a3e1b08e7852e83293c1e4b3b8d7db"},
{file = "qh3-2.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:9d505d790ab10e378cf4e5c2d6447cb8b602d9cc36a4113160f9ece7bba9abc6"},
{file = "qh3-2.0.4-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ec087930c9b43d1e32c5c4cdc8d550fcb72cbfcc957c1d922859ce11b1e55663"},
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:406577d5fc72bed8d12b6397099b4e164948171dd0a16be8e768b31f1ef155ae"},
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e94ba4e929e0e54f615c4e28493980af5d82a6d928c644a2da26983df73e110"},
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:03015a711f8b1ab24825d6ac6d2af1874d4157ac886d236bc19f7a21e69377b6"},
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:fda466d648cebffccca1a68d9716b31d41eb09b284ecf17705b4a04991dbb881"},
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:06e8c7ad92fcb482de360af5ae00d8e4559e969d1559a5a88b68989c6bac22c4"},
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6c0907ab53bd1fb1cca0a309cf26df42c9862e43e40c79d0e5ac3d2b6b747689"},
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af34fad8554560fa38304f61708176379d5efb192da2ece5fb25dec23c304b36"},
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:b6760be9bc0401bf7e6c1e889181587619529f34ffcf3f8a8de5f074815dc2ab"},
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a29a0c05b7986ae0400a4229ca7176805cd44774675663b4ab0d4b6ebef7655c"},
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:3dcedaeaf791114a050762b097b18839f6cc491dbe83873137b080f7b6dd1411"},
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:8a8fbc4f30683a47dfb2b9eebc95e5f8b76c3a1eb818b0dd3354aad54c2d99f6"},
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:d777f66879bda1e2b73caa2ca624045362d3aab8b474d2db868d0f284822e24c"},
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1dad9bae44479f682cad2e0c0ea8cbd384ce42cd2921f1f1b4c03e1299f6acbc"},
{file = "qh3-2.0.4-cp37-abi3-win32.whl", hash = "sha256:45d57837ef9990c58c1776a12d551e7c88f2592a9885b6e781d30e18062b57c6"},
{file = "qh3-2.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:53f7180a74db23f5469a610a6bcdf2b29a4ed45e067a40276c83b1cead3816df"},
{file = "qh3-2.0.4-cp37-abi3-win_arm64.whl", hash = "sha256:79e93801a8f33e651a7751bde3e0e120b565a93d18d146bf1fc8127d1669494f"},
{file = "qh3-2.0.4.tar.gz", hash = "sha256:72b174c9dabfccdddf050dabc3c1b49972ed7a7564896d3db558e57376187f75"},
{file = "qh3-2.1.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ba62fe5a0a8394e3cff6b1e205d4cdb21f18d9191193d963fc887a1f7339d3cf"},
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:962cdfab72c26b5fa9549c5bd1fe645ca437a449ee2c1528aff226e3264452ff"},
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ec9b4cc8796a7a158f05667200fbc9906ccf8ab6804bf007c61b17901035ebe"},
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65b2cb6aba980a5821223969d15452e102e01f1b656485b401c40dd0d4d93c8c"},
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:82505dbb6e5a5aa814fac47789b185b21020692c37ccfd96d428d32c9822ee5d"},
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5d68107e1c2bb6538bfeddd0238cfd309396acb51e0d3bd0fd7ae87390219b6"},
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e856c5c9393b1fb4c9e7458165ad2560ec8b0c14e6092c4e8f456dcba8ce3f1f"},
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f647ef9f76bd289a28baab858dcfd34efd2255a7da274a9e8f45a020be36b795"},
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:86ebca1a4d4874c619ef7d3eee4d699968b236e4292bb4cbd498c7054cdc5319"},
{file = "qh3-2.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:4c21a3133460449abd93a61cc675dbde893f1094f18bc6f286d44d3d3ae5d462"},
{file = "qh3-2.1.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:0b3d1ddf862bb9ffde56d4ef4477a0bb86fe60983d6b03b7e8869aa026c4c3d4"},
{file = "qh3-2.1.0-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:b812694d3884bf8f92ba05b02889f41ea519821fc98c2634010e5d595ec2c330"},
{file = "qh3-2.1.0-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:a80118168ebf68413b089930fcef0d6c7ba2907d61273a4fcd72414322281d75"},
{file = "qh3-2.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a6013c83b0988544ca084feea6c91b4dceb9b9a835a4c2eec0407faf820ba4d2"},
{file = "qh3-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:358f66b86c6ff4ac515aaf8c6848bf0e61939b075f8fa455b977d0aa3e7e6860"},
{file = "qh3-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1bd9d323d3a059d79c3586c00f973d64ba803234218d47ca2e8a364ec885b9dd"},
{file = "qh3-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7644fc608c6d28012f567c5ce060b281c5a014ff60ddf4167ba9b6507ef3e576"},
{file = "qh3-2.1.0-cp315-cp315t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a07c074dcb9d58b41d1d73207357bd70e2617ed9816f36bbc5824eeef4465c1b"},
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6dd04f2718a3412bdebd580e35ff465128ba3d6dfa967c0ce4186f60a2df060b"},
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b2304539fee9c70d385bd834402ae3fbfff58fcb2793adf7fd44bd53cc983be0"},
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:451ae203b2670e256333170afc2ccc558fa15de172412ec02c2ea3e03854645f"},
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:129ff9821720341c847dffe99f8fe599914f583cfe0c725061ecfd5d578ee2b8"},
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a648d7ef66894113a23b0b197b9c164e8d6cd6a1175e10c1ba5bfc1d5f02eab1"},
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:833a93ff543f4e98b71b497841c5c3ac4c7f255d56f544f5223ffd413dca2efb"},
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e649419464428547e314c8048e5257d9a8cd09e15643405cc9fbe177e10cad37"},
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1d6e9364a537785755b5b2724c330ed59fbae8bc29df28a1a74cb83ef1defd4c"},
{file = "qh3-2.1.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:4c3fcb8ec0da9ad2a349a2a0b96902e0432830e6740163863911d1ea88439fad"},
{file = "qh3-2.1.0-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d15f365e295d1926bb283324c72dfb2568cdb145a2d521495b562c765a9e3507"},
{file = "qh3-2.1.0-cp315-cp315t-musllinux_1_1_i686.whl", hash = "sha256:ba8598d3ee4c72325d099714920f23c35844a0e2f0bf20dd56b51d61545bc097"},
{file = "qh3-2.1.0-cp315-cp315t-musllinux_1_1_riscv64.whl", hash = "sha256:67e6377dad202507d50e4af854a6a464975ed310f4ecc272f83cda73811e9b71"},
{file = "qh3-2.1.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:9915fd34cf3010f186c783a492c9f3ec31c0e5cad8c5e16a6fe08d2dda1defee"},
{file = "qh3-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:71ffe60e5a8dc67b7ecd3b2ba6a105fe4cda8e770d6fae2ff6445b557d0b4d38"},
{file = "qh3-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8a1a95c687bd2d26e7cb55dc14095b95ab55f1afcd8adfe1c885fa7818582ad7"},
{file = "qh3-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:2eefd993b952bb2bff34396b7abf4cffa46f25667cba85de0dff6e083d1e83ac"},
{file = "qh3-2.1.0-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:94412f03ec7b2d27e9f238404de0ab0a293ef4eb172ce1e6f6f6fd11ab857e58"},
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b66c2ff3d8c5bad514f7f9362e0c9e3e4664209db5c0167278f1d90918bbd12b"},
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8982eae1cd88e5d7700802b82f29a7cb834d453bb557827010199a037d4062f9"},
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fbdfefe22dcc6222b853be72f062fc7e84d6c333294310502bf0090e20ac0cf3"},
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:341793106cb0f803985031690bc2e4e184bbeecbaed63d902921c7dfd07f1808"},
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b205fd9b5fffcf467331b82ae1f4e360581bd60886d37adf626197e75e0e0bc"},
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27bd2e3a03985be5e6e6a04faa1f53106c75ad607f662d54b57585a0e52121c1"},
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1808e123760e78482948b8e6bf5547a8a91637573b89caf165ffe948850d2ba5"},
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:343780d1f7e8000a6e06458e6940d0a80e29cc2be7e47b60f24f714dd17da690"},
{file = "qh3-2.1.0-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:3cb6649dee2f4d3dba008dc9d0e15a75fdb0c16b864535725f2266adc55a54e9"},
{file = "qh3-2.1.0-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:23ff27b10d88fae56fd3fb530bd93a19dcce7b3ee921b9cebc4d75767b2b71a1"},
{file = "qh3-2.1.0-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:a51ef02b036b2a06293ce87d48105a516a31694a77746062d16016a5aca6a013"},
{file = "qh3-2.1.0-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:81a03727a5ddf001fdb57251a86b950ffea47c7a3473641e2707d647d941b398"},
{file = "qh3-2.1.0-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5b7e7300dc9352f7d31b4204e6bad6c0a310a12c4b40e36a0ff347f64c8d793f"},
{file = "qh3-2.1.0-cp37-abi3-win32.whl", hash = "sha256:19d1b93d669177215675bd21a303763d2222bc9ba1c9c3850e1e9a5bdfe868aa"},
{file = "qh3-2.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:85be2ef6a536a687d12c018f4605a5517b151d02ea7d42a3d9f4386985c735a5"},
{file = "qh3-2.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:0c257cef34cf990da943ffbf12c4ee2ab44d131f4b4024383edae84d19428056"},
{file = "qh3-2.1.0.tar.gz", hash = "sha256:622e3c63f2417039785f61d6af2cb26d0f0e5935ac2b84ef85a2bf76862fe6f3"},
]
[[package]]
@@ -659,29 +676,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 +714,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 +722,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]]
@@ -773,7 +790,7 @@ files = [
[[package]]
name = "urllib3-future"
version = "2.25.903"
version = "2.25.905"
requires_python = ">=3.7"
summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces"
groups = ["default"]
@@ -783,120 +800,145 @@ dependencies = [
"qh3<3.0.0,>=1.5.4; (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\"",
]
files = [
{file = "urllib3_future-2.25.903-py3-none-any.whl", hash = "sha256:39352d455023402d86c4c1f567eba4f8b1bbf65571f8df96c2f5697c8058825f"},
{file = "urllib3_future-2.25.903.tar.gz", hash = "sha256:ef0011def1c6724f418d7970200ab8ccaf7d034ece152717b368afe512fb6d53"},
{file = "urllib3_future-2.25.905-py3-none-any.whl", hash = "sha256:c8e3629be86cf3c074769a23beb2b99993cc3454d2049bd3c0643952f6bfbafb"},
{file = "urllib3_future-2.25.905.tar.gz", hash = "sha256:d5bd25dad01d7358b33f4c7b3f225506231158086ff790028c1de5b38c26e89e"},
]
[[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]]
name = "winrt-runtime"
version = "3.2.1"
requires_python = ">=3.9"
summary = "Python projection of Windows Runtime (WinRT) APIs"
version = "4.0.3"
requires_python = ">=3.11"
summary = "The runtime that every PyWinRT projection package runs on"
groups = ["notify"]
marker = "sys_platform == \"win32\""
dependencies = [
"typing-extensions>=4.12.2",
]
files = [
{file = "winrt_runtime-3.2.1-cp314-cp314-win32.whl", hash = "sha256:9b6298375468ac2f6815d0c008a059fc16508c8f587e824c7936ed9216480dad"},
{file = "winrt_runtime-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:e36e587ab5fd681ee472cd9a5995743f75107a1a84d749c64f7e490bc86bc814"},
{file = "winrt_runtime-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:35d6241a2ebd5598e4788e69768b8890ee1eee401a819865767a1fbdd3e9a650"},
{file = "winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea"},
{file = "winrt_runtime-4.0.3-cp314-cp314-win32.whl", hash = "sha256:fee35995e7dfb083b5024d7a11d63ad2d2c74e7a9fc6a58dcdede52c1f0a321d"},
{file = "winrt_runtime-4.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:03d76d06172b6f5bb6312aa24f0d25761b1e45fe2b7c3c0b4ad0cd73a06bb293"},
{file = "winrt_runtime-4.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:9731462174346a2ede57660b9d521e41a16cd7e59890dc292c4250c42d1642a9"},
{file = "winrt_runtime-4.0.3-cp314-cp314t-win32.whl", hash = "sha256:dc545f834a3dd2bcc69512193677f0918788ccce4871ca24937aa6f171ea7112"},
{file = "winrt_runtime-4.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b0b06be21eb17248ce32d43a4a027ba22651065f9af5b42c3454575766879e1e"},
{file = "winrt_runtime-4.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2b8d6a55d695e54a25e262a6d1134c510a1e291cf2c3d141990a8c209f87dc40"},
{file = "winrt_runtime-4.0.3-cp315-cp315-win32.whl", hash = "sha256:ff7920a2d42734c545984ef671376713ce09f8e59fc9a5b420395406e2c1eea7"},
{file = "winrt_runtime-4.0.3-cp315-cp315-win_amd64.whl", hash = "sha256:403a80372f33635851814760d1acd6d8bbdb0dedff7812203d0dd4cfba809e8b"},
{file = "winrt_runtime-4.0.3-cp315-cp315-win_arm64.whl", hash = "sha256:52eb937ab71e39ddf798f16dfe47d572a8c097ac6d1eb2ddb700fc7977591241"},
{file = "winrt_runtime-4.0.3-cp315-cp315t-win32.whl", hash = "sha256:325a701c70e32b31139bf650c31fa352f4eae108fb0488aad074fb7026b1e650"},
{file = "winrt_runtime-4.0.3-cp315-cp315t-win_amd64.whl", hash = "sha256:f0e5ce8fc5e6453fbad7ed3197010586337a9a4d42fce29847d8a5573f29d8e9"},
{file = "winrt_runtime-4.0.3-cp315-cp315t-win_arm64.whl", hash = "sha256:9580aaf167a9e1d9d790b57e9dfaa5499c8a625bd85f3872a5097915d25f1c24"},
{file = "winrt_runtime-4.0.3.tar.gz", hash = "sha256:b04877a5b1f2ca67084d62731546f7b512c8291e2b70b95def82a1c4d0bfe993"},
]
[[package]]
name = "winrt-windows-applicationmodel-core"
version = "3.2.1"
requires_python = ">=3.9"
summary = "Python projection of Windows Runtime (WinRT) APIs"
version = "4!10.0.28000.2705"
requires_python = ">=3.11"
summary = "PyWinRT projection of the Windows.ApplicationModel.Core namespace"
groups = ["notify"]
marker = "sys_platform == \"win32\""
dependencies = [
"winrt-runtime~=3.2.1.0",
"winrt-runtime<5,>=4.0.0",
"winrt-windows-foundation-collections>=4!10.0.28000.2705",
"winrt-windows-foundation>=4!10.0.28000.2705",
]
files = [
{file = "winrt_windows_applicationmodel_core-3.2.1-cp314-cp314-win32.whl", hash = "sha256:2a940b018989ba7159ace4c93eab5f5bce303ac5325ab29eeb0effe1d4a486a7"},
{file = "winrt_windows_applicationmodel_core-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:52f2e1cd5fa0a5b0592ddc40b423255491a40a2d6e4cf6ae766dd3b4810557ec"},
{file = "winrt_windows_applicationmodel_core-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:a0c18662c835fbb5044a5c3ca021d8f2f3f290c6238543ed74112a18e3d7d878"},
{file = "winrt_windows_applicationmodel_core-3.2.1.tar.gz", hash = "sha256:7cd21a4a7e25b099703a607f17ce804b5166cdf6444437440590fafea990fb46"},
{file = "winrt_windows_applicationmodel_core-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:c6c0a854e8ab3620899ae83671bb30853183cc43d7b7ab9d2cb11fe60f2e1138"},
{file = "winrt_windows_applicationmodel_core-4!10.0.28000.2705.tar.gz", hash = "sha256:f06920b88d030266cd2c0ef270dbcf3dec1f438e95e458933cba18103d35902b"},
]
[[package]]
name = "winrt-windows-data-xml-dom"
version = "3.2.1"
requires_python = ">=3.9"
summary = "Python projection of Windows Runtime (WinRT) APIs"
version = "4!10.0.28000.2705"
requires_python = ">=3.11"
summary = "PyWinRT projection of the Windows.Data.Xml.Dom namespace"
groups = ["notify"]
marker = "sys_platform == \"win32\""
dependencies = [
"winrt-runtime~=3.2.1.0",
"winrt-runtime<5,>=4.0.0",
"winrt-windows-foundation-collections>=4!10.0.28000.2705",
"winrt-windows-foundation>=4!10.0.28000.2705",
"winrt-windows-storage-streams>=4!10.0.28000.2705",
]
files = [
{file = "winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win32.whl", hash = "sha256:77258030a725a32359fd2a93855cafa0e3f3f38e67afd5dfe74a8a215cbb31c4"},
{file = "winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:1cf1b6f31fff4e4c0ae30f1643b169da72b3b053a2996010f2c3a1e26b5d4970"},
{file = "winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:6de83f853d889444e4897413c2fa8183f86aa602edd34abf36a4fdc69db1650e"},
{file = "winrt_windows_data_xml_dom-3.2.1.tar.gz", hash = "sha256:db81f5956bf63ce8c9d8bfb6d0794ab2849f26e62a6256b833af8de3d6451399"},
{file = "winrt_windows_data_xml_dom-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:5784183aca5a7f0f297e21789de34312702207e767954dc96d4197588c714a5a"},
{file = "winrt_windows_data_xml_dom-4!10.0.28000.2705.tar.gz", hash = "sha256:9dade5d5e6768077009babc965c56e0d39be53c329e81ad06867bfb2b82516dd"},
]
[[package]]
name = "winrt-windows-foundation"
version = "3.2.1"
requires_python = ">=3.9"
summary = "Python projection of Windows Runtime (WinRT) APIs"
version = "4!10.0.28000.2705"
requires_python = ">=3.11"
summary = "PyWinRT projection of the Windows.Foundation namespace"
groups = ["notify"]
marker = "sys_platform == \"win32\""
dependencies = [
"winrt-runtime~=3.2.1.0",
"winrt-runtime<5,>=4.0.0",
"winrt-windows-foundation-collections>=4!10.0.28000.2705",
]
files = [
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win32.whl", hash = "sha256:35e973ab3c77c2a943e139302256c040e017fd6ff1a75911c102964603bba1da"},
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22a7ebcec0d262e60119cff728f32962a02df60471ded8b2735a655eccc0ef5"},
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be7fbae829b98a6a946db4fbaf356b11db1fbcbb5d4f37e7a73ac6b25de8b87"},
{file = "winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656"},
{file = "winrt_windows_foundation-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:b409530668b101cb17b3a6ce02061b9839665c92b64b0563c106186d87562571"},
{file = "winrt_windows_foundation-4!10.0.28000.2705.tar.gz", hash = "sha256:eb33cc2fdf0d8c5358cb7a731563118de0f16ddc75e256b8fd83c8117a6405a0"},
]
[[package]]
name = "winrt-windows-foundation-collections"
version = "3.2.1"
requires_python = ">=3.9"
summary = "Python projection of Windows Runtime (WinRT) APIs"
version = "4!10.0.28000.2705"
requires_python = ">=3.11"
summary = "PyWinRT projection of the Windows.Foundation.Collections namespace"
groups = ["notify"]
marker = "sys_platform == \"win32\""
dependencies = [
"winrt-runtime~=3.2.1.0",
"winrt-runtime<5,>=4.0.0",
"winrt-windows-foundation>=4!10.0.28000.2705",
]
files = [
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win32.whl", hash = "sha256:33188ed2d63e844c8adfbb82d1d3d461d64aaf78d225ce9c5930421b413c45ab"},
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d4cfece7e9c0ead2941e55a1da82f20d2b9c8003bb7a8853bb7f999b539f80a4"},
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3884146fea13727510458f6a14040b7632d5d90127028b9bfd503c6c655d0c01"},
{file = "winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5"},
{file = "winrt_windows_foundation_collections-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:f2169228644b86d752dca6f4ba0bb55ee27c5cc1062f3a3f0fbb375f48894bd7"},
{file = "winrt_windows_foundation_collections-4!10.0.28000.2705.tar.gz", hash = "sha256:8d396ea352d3e57a534223314aa935c2fe3e3edaa6b38439b13d5ddfeb5a12ea"},
]
[[package]]
name = "winrt-windows-storage-streams"
version = "4!10.0.28000.2705"
requires_python = ">=3.11"
summary = "PyWinRT projection of the Windows.Storage.Streams namespace"
groups = ["notify"]
marker = "sys_platform == \"win32\""
dependencies = [
"winrt-runtime<5,>=4.0.0",
"winrt-windows-foundation-collections>=4!10.0.28000.2705",
"winrt-windows-foundation>=4!10.0.28000.2705",
]
files = [
{file = "winrt_windows_storage_streams-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:2824e516e328c9ba76ffc7926431255b124b4a9f52c656f0b1e0056a7f8ca7b8"},
{file = "winrt_windows_storage_streams-4!10.0.28000.2705.tar.gz", hash = "sha256:9511b19e9cc588dcc81969b2871e465385f2ab37a62a9019f90dc37e03a5dd16"},
]
[[package]]
name = "winrt-windows-ui-notifications"
version = "3.2.1"
requires_python = ">=3.9"
summary = "Python projection of Windows Runtime (WinRT) APIs"
version = "4!10.0.28000.2705"
requires_python = ">=3.11"
summary = "PyWinRT projection of the Windows.UI.Notifications namespace"
groups = ["notify"]
marker = "sys_platform == \"win32\""
dependencies = [
"winrt-runtime~=3.2.1.0",
"winrt-runtime<5,>=4.0.0",
"winrt-windows-foundation-collections>=4!10.0.28000.2705",
"winrt-windows-foundation>=4!10.0.28000.2705",
]
files = [
{file = "winrt_windows_ui_notifications-3.2.1-cp314-cp314-win32.whl", hash = "sha256:00a7579ca0870134c27def1418c259fb18efe5b88aa399de85b4712f1add1b55"},
{file = "winrt_windows_ui_notifications-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:943599c727abf710ae94644b1d521e11857bd568e080e894a8be11aa717e383a"},
{file = "winrt_windows_ui_notifications-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:2a496c7e796b537966df7b26753b20bc40c5c1fbe96b3b3051a1debc00b80f07"},
{file = "winrt_windows_ui_notifications-3.2.1.tar.gz", hash = "sha256:eb29bf1c3306f3ceabd71d092b5505d5fe73fbc0660347dcc63ebc4204d34d21"},
{file = "winrt_windows_ui_notifications-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:909fdfe7dc7418adc59029fa723f24db5b28836bc017d791b03e4e7cda02117c"},
{file = "winrt_windows_ui_notifications-4!10.0.28000.2705.tar.gz", hash = "sha256:8fd5a28f81ef4b422b444b2889e2dec6a316692b7c2351a6daff39ccaf591bba"},
]
+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.12.0"
version = "0.15.0"
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",
]
+5
View File
@@ -28,9 +28,12 @@ from .notices import has_notice as has_notice
from .notices import resume_notice as resume_notice
from .notices import skill_tools_notice as skill_tools_notice
from .notices import truncated_turn_notice as truncated_turn_notice
from .tools import Approval as Approval
from .tools import BatchConfirmDecision as BatchConfirmDecision
from .tools import DangerClassifier as DangerClassifier
from .tools import DangerReason as DangerReason
from .tools import Denial as Denial
from .tools import DenialKind as DenialKind
from .tools import ToolBatchConfirmHook as ToolBatchConfirmHook
from .tools import ToolConfirmHook as ToolConfirmHook
from .tools import ToolConfirmRequest as ToolConfirmRequest
@@ -38,6 +41,8 @@ from .tools import ToolDefinition as ToolDefinition
from .tools import ToolOutput as ToolOutput
from .tools import ToolRegistry as ToolRegistry
from .tools import ToolTag as ToolTag
from .tools import format_approval as format_approval
from .tools import most_specific_approval as most_specific_approval
from .tools import schema_from_callable as schema_from_callable
from .tools import tool as tool
from .usage import TokenUsage as TokenUsage
+65 -22
View File
@@ -21,6 +21,16 @@ if TYPE_CHECKING:
type TokenMeasure = Callable[[Sequence[AnyChatMessage]], int]
DEFAULT_TOOL_RESULT_MAX_CHARS = 32_000
# Storage ceiling for one tool result kept whole in its message. A result at or
# below this is stored in full (the request-prep cap trims it on the wire); one
# above it keeps its head and says at that head how much was dropped, since the
# dropped tail is past what a message keeps and no document can serve it.
DEFAULT_HARD_MAX_TOOL_RESULT_CHARS = 2_000_000
# Leading notice of a body the storage ceiling cut. It leads rather than trails
# because the wire cap sends the head only: a notice at the end of a 2 MB body
# would be readable only by fetching the whole thing back through its document.
_CEILING_NOTICE_HEAD = "[Stored at the {max_chars}-char ceiling: {omitted} "
_CEILING_NOTICE_TAIL = "chars beyond it were dropped and cannot be read back]"
# Soft context budget in tokens (API-calibrated when possible; else ~4 chars/token).
DEFAULT_CONTEXT_MAX_TOKENS = 200_000
DEFAULT_OLD_TOOL_RESULT_CHARS = 800
@@ -36,26 +46,64 @@ IMAGE_CHARS = 4_096
DEFAULT_CONTEXT_MAX_CHARS = DEFAULT_CONTEXT_MAX_TOKENS * 4
def truncate_tool_result(
text: str,
max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
) -> str:
"""Cap tool output so huge dumps do not flood model context.
def cut_at_storage_ceiling(text: str) -> bool:
"""Whether a stored tool body was cut by :func:`cap_hard_tool_text`."""
head, _, _ = text.partition("\n")
return head.startswith("[Stored at the ") and head.endswith(_CEILING_NOTICE_TAIL)
This is the generic agent-loop cap for arbitrary tool results. It embeds a
memory truncate token via :func:`truncate_generic` so the model can always
resume the remainder with ``get_truncated`` — resumable or not (remainders
live in the in-memory store for the process lifetime). The compact marker
``[Truncated (N chars max.; M omitted). truncate_token=...]`` is emitted
only when output is actually cut.
def cap_hard_tool_text(text: str, max_chars: int = DEFAULT_HARD_MAX_TOOL_RESULT_CHARS) -> str:
"""Bound what one tool message stores (the storage ceiling).
The request-prep pass trims a result on the wire and names a document for the
stored remainder; this ceiling bounds the stored body itself. A result above
it keeps its head behind a leading notice that says how much was dropped: the
dropped tail is past what a message keeps, so no document holds it and a
reader has to be told that up front. ``max_chars < 1`` stores the result whole.
"""
if max_chars < 1:
if max_chars < 1 or len(text) <= max_chars:
return text
if len(text) <= max_chars:
return text
from plyngent.tools.truncate_token import truncate_generic
omitted = len(text) - max_chars
notice = _CEILING_NOTICE_HEAD.format(max_chars=max_chars, omitted=omitted) + _CEILING_NOTICE_TAIL
return f"{notice}\n{text[:max_chars]}"
return truncate_generic(text, max_chars)
def cap_tool_message(message: ToolChatMessage, max_chars: int) -> ToolChatMessage:
"""Trim one tool result for the wire, naming the document that holds the rest.
The fixed rule of the request-prep pass: a result over ``max_chars`` keeps its
head and gains a marker whose ``document_id`` (the message's ``tool_call_id``)
lets the model read the rest with ``read_document`` / ``search_document``. The
stored message stays whole; only the returned copy is trimmed. A body the
storage ceiling already cut says so in the marker too, so the document is not
mistaken for the complete output.
"""
if max_chars < 1 or len(message.content) <= max_chars:
return message
from plyngent.tools.documents import document_marker
total = len(message.content)
cut = cut_at_storage_ceiling(message.content)
head_len = max(1, max_chars - len(document_marker(message.tool_call_id, max_chars, total, stored_cut=cut)))
marker = document_marker(message.tool_call_id, head_len, total, stored_cut=cut)
return msgspec.structs.replace(message, content=message.content[:head_len] + marker)
def truncate_for_request(
messages: Sequence[AnyChatMessage],
*,
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
) -> list[AnyChatMessage]:
"""The request copy with every tool result trimmed to the fixed wire cap.
Runs in the request-prep pass, after soft compact. Each trimmed result names
its document, so the model reads the rest on demand instead of carrying it in
every request.
"""
return [
cap_tool_message(message, max_tool_result_chars) if isinstance(message, ToolChatMessage) else message
for message in messages
]
def _content_chars(content: str | list[ChatTextPart | ChatImagePart]) -> int:
@@ -131,12 +179,7 @@ def measure_messages_tokens(
def _shrink_tool(message: ToolChatMessage, max_chars: int) -> ToolChatMessage:
if len(message.content) <= max_chars:
return message
return msgspec.structs.replace(
message,
content=truncate_tool_result(message.content, max_chars),
)
return cap_tool_message(message, max_chars)
def _tool_indices(messages: Sequence[AnyChatMessage]) -> list[int]:
+63 -18
View File
@@ -20,6 +20,7 @@ from plyngent.lmproto.openai_compatible.model import (
from .budget import (
DEFAULT_CONTEXT_MAX_TOKENS,
DEFAULT_HARD_MAX_TOOL_RESULT_CHARS,
DEFAULT_TOOL_RESULT_MAX_CHARS,
estimate_messages_tokens,
)
@@ -56,10 +57,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 +78,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 +86,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.
@@ -306,6 +329,7 @@ class ChatAgent:
stream: bool
system_prompt: str | None
max_tool_result_chars: int
hard_max_tool_result_chars: int
parallel_tools: bool
max_context_tokens: int
todo_stack: TodoStack | None
@@ -340,6 +364,7 @@ class ChatAgent:
stream: bool = True,
system_prompt: str | None = None,
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
hard_max_tool_result_chars: int = DEFAULT_HARD_MAX_TOOL_RESULT_CHARS,
parallel_tools: bool = True,
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
todo_stack: TodoStack | None = None,
@@ -350,6 +375,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
@@ -363,6 +391,7 @@ class ChatAgent:
self.stream = stream
self.system_prompt = system_prompt
self.max_tool_result_chars = max_tool_result_chars
self.hard_max_tool_result_chars = hard_max_tool_result_chars
self.parallel_tools = parallel_tools
self.max_context_tokens = max_context_tokens
self.todo_stack = todo_stack
@@ -623,6 +652,7 @@ class ChatAgent:
on_limit=self.on_limit,
stream=self.stream,
max_tool_result_chars=self.max_tool_result_chars,
hard_max_tool_result_chars=self.hard_max_tool_result_chars,
parallel_tools=self.parallel_tools,
max_context_tokens=self.max_context_tokens,
todo_stack=self.todo_stack,
@@ -676,14 +706,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,
@@ -778,6 +814,7 @@ class ChatAgent:
stream=self.stream,
system_prompt=self.system_prompt if system_prompt is None else system_prompt,
max_tool_result_chars=self.max_tool_result_chars,
hard_max_tool_result_chars=self.hard_max_tool_result_chars,
parallel_tools=self.parallel_tools,
max_context_tokens=self.max_context_tokens,
todo_stack=None,
@@ -803,8 +840,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)
+2 -1
View File
@@ -25,7 +25,8 @@ use `run_argv_batch` for ordered pipelines.
`skill_read` slices a skill file the same way).
- `regex_files`: `pattern` is a Python `re` regex (not a glob, so `*.py` fails) and \
`path` is a literal file/dir (no glob expansion).
- Truncated results carry a `truncate_token=...`; continue with `get_truncated`.
- A long tool result carries a `document_id=...`; read the rest with `read_document` \
(line window) or `search_document` (regex).
- Prefer `fetch` for public pages (clean Markdown); `fetch_local` for localhost/LAN, \
non-GET methods, headers, or raw status; `offset` (0-based) skips leading body characters.
- PTY secrets only via `ask_into_pty`; denylists and confirms still apply.
+33 -19
View File
@@ -25,10 +25,12 @@ from plyngent.typedef import Unset # noqa: TC001
from .budget import (
DEFAULT_CONTEXT_MAX_TOKENS,
DEFAULT_HARD_MAX_TOOL_RESULT_CHARS,
DEFAULT_TOOL_RESULT_MAX_CHARS,
cap_hard_tool_text,
compact_messages_for_request,
estimate_messages_tokens,
truncate_tool_result,
truncate_for_request,
)
from .directive_checkpoint import (
DEFAULT_DIRECTIVE_REMINDER_TOKENS,
@@ -88,7 +90,7 @@ async def _run_one_tool(
tools: ToolRegistry,
call: AnyAssistantToolCall,
*,
max_result_chars: int,
hard_max_chars: int,
decision: BatchConfirmDecision | None = None,
) -> tuple[ToolChatMessage, ErrorEvent | None]:
if isinstance(call, AssistantFunctionToolCall):
@@ -99,13 +101,13 @@ async def _run_one_tool(
except Exception as exc: # noqa: BLE001
result_text = f"error: tool {call.function.name!r} failed: {exc}"
err = ErrorEvent(message=result_text, retryable=True, source="tool")
truncated = truncate_tool_result(result_text, max_result_chars)
return ToolChatMessage(content=truncated, tool_call_id=call.id), err
# Only the text is capped; an image the tool returned rides along whole.
truncated = truncate_tool_result(output.text, max_result_chars)
return ToolChatMessage(content=cap_hard_tool_text(result_text, hard_max_chars), tool_call_id=call.id), err
# The result is stored whole (up to the storage ceiling); the
# request-prep pass trims it on the wire and names its document. An image
# the tool returned rides along whole.
return (
ToolChatMessage(
content=truncated,
content=cap_hard_tool_text(output.text, hard_max_chars),
tool_call_id=call.id,
images=output.images or UNSET,
),
@@ -127,7 +129,7 @@ async def _execute_tool_calls(
tool_calls: Sequence[AnyAssistantToolCall],
messages: list[AnyChatMessage],
*,
max_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
hard_max_chars: int = DEFAULT_HARD_MAX_TOOL_RESULT_CHARS,
parallel: bool = True,
) -> AsyncIterator[AgentEvent]:
for call in tool_calls:
@@ -149,13 +151,13 @@ async def _execute_tool_calls(
if parallel and len(tool_calls) > 1:
results = await asyncio.gather(
*[
_run_one_tool(tools, call, max_result_chars=max_result_chars, decision=decision)
_run_one_tool(tools, call, hard_max_chars=hard_max_chars, decision=decision)
for call, decision in zip(tool_calls, decisions, strict=True)
]
)
else:
results = [
await _run_one_tool(tools, call, max_result_chars=max_result_chars, decision=decision)
await _run_one_tool(tools, call, hard_max_chars=hard_max_chars, decision=decision)
for call, decision in zip(tool_calls, decisions, strict=True)
]
@@ -532,6 +534,7 @@ async def run_chat_loop( # noqa: C901, PLR0915 — multi-phase tool loop
on_limit: LimitContinueHook | None = None,
stream: bool = True,
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
hard_max_tool_result_chars: int = DEFAULT_HARD_MAX_TOOL_RESULT_CHARS,
parallel_tools: bool = True,
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
todo_stack: TodoStack | None = None,
@@ -547,7 +550,14 @@ async def run_chat_loop( # noqa: C901, PLR0915 — multi-phase tool loop
When ``stream=True``, uses ``chat_completions(..., stream=True)`` and yields
text deltas as chunks arrive; tool calls are merged from stream deltas.
Multiple tool calls in one round run in parallel when ``parallel_tools``.
Request payloads may shrink older tool results when over ``max_context_tokens``.
Each round builds a request copy: soft compact shrinks older tool dumps when
over ``max_context_tokens``, then every tool result is trimmed to
``max_tool_result_chars`` with a marker naming its document (``read_document``
/ ``search_document`` read the rest). A tool result is stored whole in its
message, up to ``hard_max_tool_result_chars``; a result above that ceiling
keeps its head and says at that head how much was dropped, and its document
cannot serve the dropped tail.
*reasoning_effort* is the resolved thinking strength (``""`` = send nothing,
the provider's own default); each API surface maps it onto its own wire field
@@ -588,13 +598,17 @@ async def run_chat_loop( # noqa: C901, PLR0915 — multi-phase tool loop
while True:
while rounds_used < allowance:
rounds_used += 1
# Request copy: shrink old tool dumps, then rewrite forged todo nags
# so cleaned stacks do not re-appear with stale OPEN WORK text.
request_messages = compact_messages_for_request(
messages,
max_tokens=max_context_tokens,
prompt_tokens_hint=prompt_tokens_hint,
sent_estimate_tokens=sent_estimate_tokens,
# Request copy: soft-compact old tool dumps, then trim every result to
# the fixed wire cap (each names its document), then rewrite forged
# todo nags so cleaned stacks do not re-appear with stale OPEN WORK text.
request_messages = truncate_for_request(
compact_messages_for_request(
messages,
max_tokens=max_context_tokens,
prompt_tokens_hint=prompt_tokens_hint,
sent_estimate_tokens=sent_estimate_tokens,
),
max_tool_result_chars=max_tool_result_chars,
)
if todo_stack is not None:
_ = refresh_synthetic_todo_nags(request_messages, todo_stack)
@@ -651,7 +665,7 @@ async def run_chat_loop( # noqa: C901, PLR0915 — multi-phase tool loop
tools,
tool_calls,
messages,
max_result_chars=max_tool_result_chars,
hard_max_chars=hard_max_tool_result_chars,
parallel=parallel_tools,
):
yield event
+155 -26
View File
@@ -6,7 +6,7 @@ import types
from collections.abc import Awaitable, Callable, Mapping, Sequence
from contextlib import AsyncExitStack
from dataclasses import dataclass
from enum import Flag, auto
from enum import Flag, StrEnum, auto
from pathlib import Path
from typing import Any, cast, get_args, get_origin, get_type_hints, overload
@@ -30,10 +30,63 @@ class DangerReason:
yolo_skip: bool = True
class DenialKind(StrEnum):
"""Why a confirmation answered no — the model should not be told it declined."""
DECLINED = "declined" # the human said no
TIMEOUT = "timeout" # nobody answered before the deadline
CANCELLED = "cancelled" # the prompt was interrupted (Ctrl+C / EOF)
NO_PROMPT = "no_prompt" # nobody could be asked (no hook, non-interactive)
# How each kind reads inside a denial message.
_DENIAL_CLAUSES: dict[DenialKind, str] = {
DenialKind.DECLINED: "user declined",
DenialKind.CANCELLED: "prompt cancelled",
DenialKind.NO_PROMPT: "no confirm hook or non-interactive (denied)",
}
@dataclass(frozen=True, slots=True)
class Denial:
"""A "no" that says which no it was: the human's, or the clock's.
``seconds`` is the deadline that expired (a timeout), ``comment`` what the
human typed when they declined. A prompt returns this instead of a bare
``False`` so the message it produces can be specific.
"""
kind: DenialKind = DenialKind.DECLINED
seconds: float | None = None
comment: str | None = None
@classmethod
def from_answer(cls, answer: object) -> Denial:
"""Normalize a hook's refusal: a bare comment means the human declined."""
if isinstance(answer, Denial):
return answer
if isinstance(answer, str) and answer.strip():
return cls(comment=answer)
return cls()
@property
def user_comment(self) -> str | None:
"""The comment that belongs in a message, or ``None``."""
return (self.comment or "").strip() or None
def clause(self) -> str:
"""The words a denial message carries after the reason."""
if self.kind is DenialKind.TIMEOUT:
if self.seconds is not None:
return f"no answer within {self.seconds:g}s — denied by default"
return "no answer — denied by default"
return _DENIAL_CLAUSES[self.kind]
type ToolHandler = Callable[..., Any | Awaitable[Any]]
type DangerClassifier = Callable[[str, Mapping[str, object]], str | DangerReason | None]
# Returns True to allow, False to deny, or a non-empty str as denial reason for the model.
type ToolConfirmResult = bool | str
# Returns True to allow, or a Denial (or a bare str comment) to deny.
type ToolConfirmResult = bool | str | Denial
type ToolConfirmHook = Callable[
[str, Mapping[str, object], str],
ToolConfirmResult | Awaitable[ToolConfirmResult],
@@ -100,6 +153,66 @@ class BatchConfirmDecision:
allowed: bool
comment: str | None = None
each: bool = False
# Why the answer was no (declined vs no prompt at all); ``None`` means declined.
denial: Denial | None = None
@dataclass(frozen=True, slots=True)
class Approval:
"""How one call got past a confirmation, as the model reads it back.
``source`` names who answered — ``user`` (a human at a prompt), ``grant``
(trust recorded earlier, so nobody was asked *this* time), ``yolo`` (YOLO
mode dropped the soft confirm) or ``config`` (a configured allow, e.g.
``[agent].allow_paths`` or ``[skills].allow_write``) — and ``scope`` how far
that answer reaches: ``once``, ``round`` (a batch allow), ``session``,
``process`` or ``auto`` (mode-driven, not a grant).
"""
source: str
scope: str
@property
def label(self) -> str:
"""``user (once)`` — the words the result's first line carries."""
return f"{self.source} ({self.scope})"
# Who answered first when one call carries several records: a human prompt is the
# closest answer, an earlier grant sits second, a config allow third, and YOLO
# mode says the least about why the call was allowed.
_APPROVAL_PRIORITY = {"user": 0, "grant": 1, "config": 2, "yolo": 3}
def format_approval(approval: Approval) -> str:
"""The model-facing line naming how a call was approved."""
return f"[approval] {approval.label}"
def most_specific_approval(approvals: Sequence[Approval]) -> Approval | None:
"""The one record a result carries (ties keep the first recorded)."""
if not approvals:
return None
return min(approvals, key=lambda a: _APPROVAL_PRIORITY.get(a.source, len(_APPROVAL_PRIORITY)))
@dataclass(frozen=True, slots=True)
class _Gate:
"""What the soft gate decided for one call: a denial, else how it was allowed."""
denied: str | None = None
approval: Approval | None = None
def _with_approval(output: ToolOutput, approval: Approval | None) -> ToolOutput:
"""Lead *output* with its approval line (nothing to add: unchanged).
An ``error:`` result carries no line: the call did not finish, so the answer
that let it start is not what the model has to read first.
"""
if approval is None or output.text.startswith("error: "):
return output
return msgspec.structs.replace(output, text=f"{format_approval(approval)}\n{output.text}")
# One prompt for a round's requests; a single request keeps ``ToolConfirmHook``.
@@ -341,11 +454,17 @@ def _decode_tool_args(arguments_json: str) -> dict[str, object] | str:
return {str(key): value for key, value in cast("dict[object, object]", raw_args).items()}
def _denied_error(name: str, reason: str, comment: str | None) -> str:
"""Model-facing text for a denied soft confirm (carries a user comment if one was given)."""
if comment is not None and comment.strip():
return f"error: tool {name!r} denied by user confirm ({reason}); user comment: {comment.strip()}"
return f"error: tool {name!r} denied by user confirm ({reason})"
def _denied_error(name: str, reason: str, denial: Denial | None) -> str:
"""Model-facing text for a denied soft confirm.
The answer that refused the call is named: a human's no carries their
comment, a silence carries its deadline, and neither is reported as the
other.
"""
denial = denial if denial is not None else Denial()
text = f"error: tool {name!r} denied ({reason}; {denial.clause()})"
comment = denial.user_comment
return f"{text}; user comment: {comment}" if comment else text
def _as_danger_reason(raw: str | DangerReason | None) -> DangerReason | None:
@@ -571,17 +690,16 @@ class ToolRegistry:
reason: str,
*,
tags: ToolTag,
) -> str | None:
) -> _Gate:
if self._on_confirm is None:
return f"error: tool {name!r} denied by policy ({reason}; no confirm hook)"
return _Gate(denied=_denied_error(name, reason, Denial(DenialKind.NO_PROMPT)))
decision = self._on_confirm(name, args, reason)
if inspect.isawaitable(decision):
decision = await decision
if decision is True:
await self._record_grant(name, tags=tags)
return None
comment = decision if isinstance(decision, str) else None
return _denied_error(name, reason, comment)
return _Gate(approval=Approval("user", "once"))
return _Gate(denied=_denied_error(name, reason, Denial.from_answer(decision)))
async def _apply_batch_decision(
self,
@@ -590,11 +708,12 @@ class ToolRegistry:
decision: BatchConfirmDecision,
*,
tags: ToolTag,
) -> str | None:
) -> _Gate:
if decision.allowed:
await self._record_grant(name, tags=tags)
return None
return _denied_error(name, reason, decision.comment)
return _Gate(approval=Approval("user", "round"))
denial = decision.denial if decision.denial is not None else Denial(comment=decision.comment)
return _Gate(denied=_denied_error(name, reason, denial))
def _prepare_confirm(self, name: str, arguments_json: str) -> ToolConfirmRequest | None:
"""Soft-confirm request for one call, or ``None`` when it needs none.
@@ -678,7 +797,7 @@ class ToolRegistry:
*,
tags: ToolTag,
decision: BatchConfirmDecision | None = None,
) -> str | None:
) -> _Gate:
"""Soft-confirm gate driven by danger reason + tags + grants + YOLO.
Pipeline (soft gate only; hard denylists stay in tool handlers)::
@@ -690,20 +809,23 @@ class ToolRegistry:
else → on_confirm (a tool without the YOLO tag, or a reason YOLO
may not drop — a privilege escalation — still prompts
under YOLO mode)
Every allow names how it happened, so the result can say it; a call that
needed no confirmation carries no approval.
"""
if self._danger is None:
return None
return _Gate()
reason = _as_danger_reason(self._danger(name, args))
if reason is None:
return None
return _Gate()
if self._yolo and (tags & ToolTag.YOLO) and reason.yolo_skip:
return None
return _Gate(approval=Approval("yolo", "auto"))
async with self._confirm_lock:
if decision is not None:
return await self._apply_batch_decision(name, reason.text, decision, tags=tags)
if self._grant_allows(name, tags=tags):
return None
return _Gate(approval=Approval("grant", "session"))
return await self._prompt_soft_confirm(name, args, reason.text, tags=tags)
async def execute(
@@ -746,6 +868,8 @@ class ToolRegistry:
return ToolOutput(text=missing_error)
async def _run() -> ToolOutput:
from plyngent.tools.context import collect_approvals
missing = self._check_state_tags(definition)
if missing is not None:
return ToolOutput(text=missing)
@@ -754,11 +878,16 @@ class ToolRegistry:
async with AsyncExitStack() as held:
for lock in self._write_locks_for(definition, args):
await held.enter_async_context(lock)
denied = await self._maybe_confirm(name, args, tags=definition.tags, decision=decision)
if denied is not None:
return ToolOutput(text=denied)
result = await self._invoke(definition, args)
return _without_refused_images(result if isinstance(result, ToolOutput) else ToolOutput(text=result))
with collect_approvals() as recorded:
gate = await self._maybe_confirm(name, args, tags=definition.tags, decision=decision)
if gate.denied is not None:
return ToolOutput(text=gate.denied)
result = await self._invoke(definition, args)
output = _without_refused_images(result if isinstance(result, ToolOutput) else ToolOutput(text=result))
# The gate's answer and whatever the handler asked on its own;
# the result carries the one closest to a human (see Approval).
approvals = [*([gate.approval] if gate.approval is not None else []), *recorded]
return _with_approval(output, most_specific_approval(approvals))
# Bind host-provided state for tools that read contextvars (todo, workspace).
# Tag enforcement remains gated by ``auto_bind_state``; binding is always useful
+16 -4
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
+109 -3
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 "?"
@@ -778,6 +808,66 @@ def _resume_detail(_args_json: str, result: str) -> str:
return _range_detail(result) or _pretty_segments(("(done)", "green"))
def _doc_read_prefix(args_json: str) -> str:
document_id = _json_str_arg(args_json, "document_id") or "?"
return _pretty_prefix(f"* Doc Read '{document_id}' ")
def _doc_read_detail(_args_json: str, result: str) -> str:
"""``read_document`` outcome: the line range read, or an error."""
if result.startswith("error:"):
return _pretty_segments((f"({result})", "red"))
return _range_detail(result) or _pretty_segments(("(done)", "green"))
def _doc_search_prefix(args_json: str) -> str:
pattern = _json_str_arg(args_json, "pattern") or "?"
document_id = _json_str_arg(args_json, "document_id") or "?"
return _pretty_prefix(f"* Doc Search '{_preview(pattern, _ASK_SUBJECT_PREVIEW)}' in '{document_id}' ")
def _doc_search_detail(_args_json: str, result: str) -> str:
"""``search_document`` outcome: the match count, none, or an error."""
if result.startswith("error:"):
return _pretty_segments((f"({result})", "red"))
if result == "(no matches)":
return _pretty_segments(("(no matches)", "dim"))
matches = [line for line in result.splitlines() if line and not line.startswith("...[truncated")]
unit = "match" if len(matches) == 1 else "matches"
return _pretty_segments((f"({len(matches)} {unit})", None))
def _doc_info_prefix(args_json: str) -> str:
document_id = _json_str_arg(args_json, "document_id") or "?"
return _pretty_prefix(f"* Doc Info '{document_id}' ")
def _doc_info_detail(_args_json: str, result: str) -> str:
"""``document_info`` outcome: the described size, or an error."""
if result.startswith("error:"):
return _pretty_segments((f"({result})", "red"))
fields = _status_fields(result)
lines = fields.get("lines", "")
chars = fields.get("chars", "")
text = f"{lines} lines, {chars} chars" if lines and chars else "done"
return _pretty_segments((f"({text})", None))
def _doc_list_prefix(_args_json: str) -> str:
return _pretty_prefix("* Doc List ")
def _doc_list_detail(_args_json: str, result: str) -> str:
"""``list_documents`` outcome: the document count, none, or an error."""
if result.startswith("error:"):
return _pretty_segments((f"({result})", "red"))
if result == "(no documents)":
return _pretty_segments(("(no documents)", "dim"))
count = len(result.splitlines())
unit = "document" if count == 1 else "documents"
return _pretty_segments((f"({count} {unit})", None))
def _vcs_kind_detail(_args_json: str, result: str) -> str:
"""``vcs_kind`` outcome: the detected kind, or an error."""
if result.startswith("error:"):
@@ -922,6 +1012,10 @@ _PRETTY_LINES: dict[str, PrettyLine] = {
"glob_paths": PrettyLine(prefix=_glob_prefix, detail=_glob_detail),
"regex_files": PrettyLine(prefix=_regex_prefix, detail=_regex_detail),
"get_truncated": PrettyLine(prefix=_resume_prefix, detail=_resume_detail),
"read_document": PrettyLine(prefix=_doc_read_prefix, detail=_doc_read_detail),
"search_document": PrettyLine(prefix=_doc_search_prefix, detail=_doc_search_detail),
"document_info": PrettyLine(prefix=_doc_info_prefix, detail=_doc_info_detail),
"list_documents": PrettyLine(prefix=_doc_list_prefix, detail=_doc_list_detail),
"run_argv": PrettyLine(prefix=_run_argv_prefix, detail=_run_argv_detail),
"run_argv_batch": PrettyLine(prefix=_run_argv_batch_prefix, detail=_run_argv_batch_detail),
"fetch": PrettyLine(prefix=_fetch_prefix, detail=_fetch_detail),
@@ -942,8 +1036,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),
@@ -975,9 +1069,21 @@ def _pretty_line_for(name: str) -> PrettyLine | None:
return _PRETTY_LINES.get(name) or _mcp_line(name)
_APPROVAL_LINE = re.compile(r"\A\[approval\] [^\n]*\n")
def _without_approval_line(result: str) -> str:
"""The tool's own text, without the leading ``[approval]`` line.
That line is for the model (the human already saw the prompt); a pretty
detail reads the result it knows, not a header in front of it.
"""
return _APPROVAL_LINE.sub("", result, count=1)
def _pretty_parts(line: PrettyLine, args_json: str, result: str) -> tuple[str, str]:
"""Prefix + detail for a resolved pretty tool call."""
return line.prefix(args_json), line.detail(args_json, result)
return line.prefix(args_json), line.detail(args_json, _without_approval_line(result))
def _echo_stream(text: str) -> None:
+1
View File
@@ -33,6 +33,7 @@ _MINIMAL_CONFIG = """\
# # system_prompt = "" / tool_directives = "" disable each part; both "" → no system.
# # Or multi-line '''...''' to override.
# max_tool_result_chars = 32000
# hard_max_tool_result_chars = 2000000
# parallel_tools = true
# confirm_destructive = true
# path_denylist = ["/secrets/", ".ssh/"]
+148 -59
View File
@@ -6,9 +6,10 @@ 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
from plyngent.agent import BatchConfirmDecision, Denial, DenialKind, ToolConfirmRequest
from plyngent.cli.display import close_open_pretty_line
from plyngent.cli.interrupt import off_loop_prompt, pause_task_cancel_for_prompt
from plyngent.prompting import (
@@ -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
@@ -239,15 +274,41 @@ def _confirm_with(wait: PromptAnswerSource | None, prompt: str, *, default: bool
return confirm(prompt, default=default, source=_answer_source(wait))
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
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 | Denial:
"""True allow; a :class:`Denial` otherwise (declined, cancelled, no prompt)."""
backend = get_prompt_backend()
if not backend.is_interactive():
return Denial(DenialKind.NO_PROMPT)
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:
return False
return Denial(DenialKind.CANCELLED)
if allowed:
return True
try:
@@ -256,11 +317,11 @@ def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str
default="",
).strip()
except NonInteractiveError:
return False
return comment or False
return Denial(DenialKind.CANCELLED)
return Denial(comment=comment or None)
def prompt_confirm_tool(name: str, args: Mapping[str, object], reason: str) -> bool | str:
def prompt_confirm_tool(name: str, args: Mapping[str, object], reason: str) -> bool | Denial:
"""Ask whether to allow a dangerous tool call (TTY). Default is deny.
Prints a multi-line boxed summary, then a short y/N prompt. On deny,
@@ -341,11 +402,12 @@ def prompt_policy_command_confirm(
basename: str,
argv: Sequence[str],
timeout_seconds: float,
) -> bool:
) -> bool | Denial:
"""Ask whether to allow a denylisted command for this session (timed).
Independent of YOLO: always prompts when interactive. Default is **deny**.
Timeout, cancel, or non-interactive → False.
Independent of YOLO: always prompts when interactive. Default is **deny**,
and the answer says which denial it was: declined, timed out, cancelled, or
not asked at all.
"""
argv_preview = " ".join(str(part) for part in argv)
reason = (
@@ -357,11 +419,11 @@ 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():
return False
return Denial(DenialKind.NO_PROMPT)
backend.echo()
backend.secho(format_tool_confirm_box("policy", reason), fg="yellow")
backend.echo()
@@ -377,10 +439,12 @@ def prompt_policy_command_confirm(
fg="red",
err=True,
)
return False
return raw.strip().lower() in {"y", "yes"}
return Denial(DenialKind.TIMEOUT, seconds=timeout_seconds)
if raw.strip().lower() in {"y", "yes"}:
return True
return Denial(DenialKind.DECLINED)
except NonInteractiveError, KeyboardInterrupt, EOFError:
return False
return Denial(DenialKind.CANCELLED)
def prompt_policy_fetch_confirm(
@@ -388,12 +452,12 @@ def prompt_policy_fetch_confirm(
port: int,
url: str,
timeout_seconds: float,
) -> bool:
) -> bool | Denial:
"""Ask whether to allow fetch_local to a private/loopback host (timed).
Independent of YOLO: always prompts when interactive. Default is **deny**.
Timeout, cancel, or non-interactive → False. On approve, the host:port is
granted for the rest of this process (see net grants).
Independent of YOLO: always prompts when interactive. Default is **deny**,
and the answer says which denial it was (see the command policy prompt).
On approve, the host:port is granted for the rest of this process.
"""
reason = (
f"fetch_local targets private/loopback host {host!r} port {port}\n"
@@ -404,11 +468,11 @@ 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():
return False
return Denial(DenialKind.NO_PROMPT)
backend.echo()
backend.secho(format_tool_confirm_box("policy", reason), fg="yellow")
backend.echo()
@@ -425,10 +489,12 @@ def prompt_policy_fetch_confirm(
fg="red",
err=True,
)
return False
return raw.strip().lower() in {"y", "yes"}
return Denial(DenialKind.TIMEOUT, seconds=timeout_seconds)
if raw.strip().lower() in {"y", "yes"}:
return True
return Denial(DenialKind.DECLINED)
except NonInteractiveError, KeyboardInterrupt, EOFError:
return False
return Denial(DenialKind.CANCELLED)
def prompt_skill_write_confirm(
@@ -441,8 +507,9 @@ def prompt_skill_write_confirm(
``o``/``y`` allows this write; ``s`` allows this skill directory for the rest
of the chat; anything else (or a timeout, cancel, or non-interactive stdin)
denies. Independent of YOLO: a skill directory is not a path grant, so the
human is asked even with ``--yes`` unless ``[skills].allow_write`` is set.
denies, and the answer says which denial it was. Independent of YOLO: a skill
directory is not a path grant, so the human is asked even with ``--yes``
unless ``[skills].allow_write`` is set.
"""
lines = [
f"action: {action} skill {skill_dir.name!r}",
@@ -453,11 +520,18 @@ 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():
return SkillWriteDecision(allow=False)
return SkillWriteDecision(allow=False, denial=Denial(DenialKind.NO_PROMPT))
backend.echo()
backend.secho(format_tool_confirm_box(f"skills {action}", "\n".join(lines)), fg="yellow")
backend.echo()
@@ -471,15 +545,15 @@ def prompt_skill_write_confirm(
if raw is None:
with contextlib.suppress(OSError, NonInteractiveError):
backend.secho(f"[skills] timed out after {timeout_seconds:g}s — denied", fg="red", err=True)
return SkillWriteDecision(allow=False)
return SkillWriteDecision(allow=False, denial=Denial(DenialKind.TIMEOUT, seconds=timeout_seconds))
token = raw.strip().lower()
if token in {"s", "session"}:
return SkillWriteDecision(allow=True, session=True)
if token in {"o", "once", "y", "yes"}:
return SkillWriteDecision(allow=True)
return SkillWriteDecision(allow=False)
return SkillWriteDecision(allow=False, denial=Denial(DenialKind.DECLINED))
except NonInteractiveError, KeyboardInterrupt, EOFError:
return SkillWriteDecision(allow=False)
return SkillWriteDecision(allow=False, denial=Denial(DenialKind.CANCELLED))
def prompt_directory_access_confirm(
@@ -487,16 +561,17 @@ def prompt_directory_access_confirm(
mode: AccessMode,
reason: str,
timeout_seconds: float,
) -> AccessDecision | str | None:
) -> AccessDecision | str | Denial:
"""Ask whether to grant access to *path* at *mode* (timed; default deny).
``y`` approves the requested level; ``read`` / ``write`` / ``exec`` approve
that level instead. The host's hook decides YOLO/``--yes`` before calling
this. Timeout, cancel, or non-interactive → ``None`` (deny).
that level instead. Nothing answers this prompt for the human, and a denial
says which one it was: declined, timed out, cancelled, or not asked at all.
"""
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}")
@@ -504,11 +579,17 @@ 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():
return None
return Denial(DenialKind.NO_PROMPT)
backend.echo()
backend.secho(
format_tool_confirm_box("request_directory_access", "\n".join(lines)),
@@ -529,25 +610,28 @@ def prompt_directory_access_confirm(
fg="red",
err=True,
)
return None
return Denial(DenialKind.TIMEOUT, seconds=timeout_seconds)
token = raw.strip().lower()
if token in {"y", "yes"}:
return AccessDecision(mode)
level = parse_access_mode(token)
if level is not None:
return AccessDecision(level)
return None
return Denial(DenialKind.DECLINED)
except NonInteractiveError, KeyboardInterrupt, EOFError:
return None
return Denial(DenialKind.CANCELLED)
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
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool | Denial:
"""Async confirm: True allows; a :class:`Denial` says why it did not."""
backend = get_prompt_backend()
if not backend.is_interactive():
return Denial(DenialKind.NO_PROMPT)
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)
@@ -555,7 +639,7 @@ async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reaso
allowed = await run_prompt_async(_run)
except NonInteractiveError:
return False
return Denial(DenialKind.CANCELLED)
if allowed:
return True
try:
@@ -566,8 +650,8 @@ async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reaso
)
).strip()
except NonInteractiveError:
return False
return comment or False
return Denial(DenialKind.CANCELLED)
return Denial(comment=comment or None)
async def prompt_confirm_batch_async(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
@@ -582,16 +666,21 @@ async def prompt_confirm_batch_async(requests: Sequence[ToolConfirmRequest]) ->
return await run_prompt_async(lambda: _prompt_confirm_batch_sync(requests))
except NonInteractiveError:
return BatchConfirmDecision(allowed=False)
return BatchConfirmDecision(allowed=False, denial=Denial(DenialKind.CANCELLED))
def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
backend = get_prompt_backend()
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:
return BatchConfirmDecision(allowed=False, denial=Denial(DenialKind.NO_PROMPT))
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")
@@ -613,7 +702,7 @@ def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchC
_ = sys.stderr.flush()
token = (_read_answer(None, wait) or "n").strip().lower()
except NonInteractiveError:
return BatchConfirmDecision(allowed=False)
return BatchConfirmDecision(allowed=False, denial=Denial(DenialKind.CANCELLED))
if token in _BATCH_MANUAL_TOKENS:
return BatchConfirmDecision(allowed=False, each=True)
if token in _BATCH_YES_TOKENS:
@@ -624,8 +713,8 @@ def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchC
default="",
).strip()
except NonInteractiveError:
comment = ""
return BatchConfirmDecision(allowed=False, comment=comment or None)
return BatchConfirmDecision(allowed=False, denial=Denial(DenialKind.CANCELLED))
return BatchConfirmDecision(allowed=False, comment=comment or None, denial=Denial(DenialKind.DECLINED))
def format_batch_confirm_box(requests: Sequence[ToolConfirmRequest]) -> str:
+3 -2
View File
@@ -15,8 +15,9 @@ the file:
``[providers.*.models.*].image_in`` skips it with a note instead of letting
the provider answer 400.
- any other file is read as text and appended to the message under a
``--- @path ---`` header, capped like a tool result; the remainder stays
resumable through the ``truncate_token`` in the marker.
``--- @path ---`` header, capped at the tool-result size; the remainder waits
in memory and the marker's ``truncate_token`` resumes it through
``get_truncated``.
Images and text both keep the typed ``@path`` in the message, so the model can
tell which attachment is which.
+219 -31
View File
@@ -3,15 +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. 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.
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
@@ -19,6 +26,7 @@ from __future__ import annotations
import asyncio
import contextlib
import importlib.util
import math
import os
import sys
import threading
@@ -38,7 +46,6 @@ if TYPE_CHECKING:
ALLOW = "allow"
DENY = "deny"
REVIEW = "review"
#: Kinds whose notification carries an answer, and the capability it needs: a
#: confirmation gets buttons, a question gets a reply field.
@@ -48,17 +55,33 @@ _ANSWER_CAPABILITY: dict[str, str] = {
"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:
@@ -68,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)
# --------------------------------------------------------------------------- #
@@ -83,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
@@ -171,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,
)
)
)
@@ -218,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.
@@ -268,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":
@@ -326,12 +427,16 @@ class WaitHandle:
#: 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 = 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
@@ -344,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()
@@ -373,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:
@@ -394,6 +521,8 @@ class WaitNotifier:
_env: Mapping[str, str]
_clock: Callable[[], float]
_last_sent: float
_handoff: bool
_source: str
def __init__(
self,
@@ -403,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):
@@ -416,6 +546,10 @@ class WaitNotifier:
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 ------------------------------------------------------------- #
@@ -426,25 +560,73 @@ 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)."""
@@ -490,16 +672,18 @@ class WaitNotifier:
if channel is None:
return False
wait = handle.wait
capability = self.answer_capability(wait.kind)
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 if capability == "buttons" else (),
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
@@ -529,12 +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
+2 -1
View File
@@ -575,7 +575,8 @@ def status_cmd(state: ReplState) -> None:
f"yolo={state.effective_yolo()}\n"
f"context_tokens={ctx_tilde}{ctx_tokens}/{ctx_budget} ({ctx_tag}) "
f"context_chars={ctx_chars} "
f"tool_result_max={state.agent.max_tool_result_chars}\n"
f"tool_result_max={state.agent.max_tool_result_chars} "
f"tool_result_store_max={state.agent.hard_max_tool_result_chars}\n"
f"peak_prompt_tokens={state.agent.peak_prompt_tokens} "
f"reminder_band={state.agent.reminder_last_band} "
f"reminder_every={state.agent.directive_reminder_tokens or 'off'}\n"
+37 -6
View File
@@ -33,6 +33,7 @@ from plyngent.tools.view import MemoryViewStore, session_data_view
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from plyngent.agent import Denial
from plyngent.config.models import Provider
from plyngent.config.store import ConfigStore
from plyngent.memory import MemoryStore
@@ -108,6 +109,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
@@ -227,6 +229,18 @@ class ReplState:
if hasattr(self, "agent") and self.agent.tools is not None:
self.agent.tools.set_session_state(self.session_state)
self.agent.tools.set_instance_state(self.instance_state)
self._bind_document_store()
def _bind_document_store(self) -> None:
"""Expose this session's tool results to the document tools.
The store resolves a ``document_id`` against the agent's live history,
which reads whole (uncapped) tool results and re-reads whatever a resume
loaded from the durable store.
"""
from plyngent.tools.documents import DocumentStore
self.instance_state.extras["documents"] = DocumentStore(lambda: self.agent.messages)
async def persist_todo_stack(self) -> None:
"""Write the in-memory todo stack to the active session row."""
@@ -283,13 +297,16 @@ class ReplState:
mode: AccessMode,
reason: str,
timeout_seconds: float,
) -> AccessDecision | str | None:
"""Directory-access confirm: YOLO/``--yes`` auto-approves, else ask."""
from plyngent.cli.limits import prompt_directory_access_confirm
from plyngent.tools.access import AccessDecision as Decision
) -> AccessDecision | str | Denial:
"""Directory-access confirm: always ask, whatever YOLO mode says.
An out-of-workspace path is a grant on the filesystem rather than one
risky call, so ``--yes`` / ``/yolo`` do not answer it — the command
denylist and a skill write are not skipped either. A non-interactive run
denies.
"""
from plyngent.cli.limits import prompt_directory_access_confirm
if self.effective_yolo() != "off":
return Decision(mode, persist=False)
return prompt_directory_access_confirm(path, mode, reason, timeout_seconds)
def _install_config_access(self) -> None:
@@ -486,6 +503,7 @@ class ReplState:
stream=self.stream_enabled,
system_prompt=system_prompt,
max_tool_result_chars=agent_cfg.max_tool_result_chars,
hard_max_tool_result_chars=agent_cfg.hard_max_tool_result_chars,
parallel_tools=agent_cfg.parallel_tools,
max_context_tokens=agent_cfg.max_context_tokens,
todo_stack=self.todo_stack,
@@ -711,11 +729,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:
+11 -4
View File
@@ -64,8 +64,9 @@ skill file the same way.
(`*.py` / `**/*.py` fail with `invalid regex`), and a literal file/dir `path` \
(no glob expansion, so `src/**/*.py` fails with `path does not exist`); there is \
no file-type filter, so narrow with `path` and filter hits yourself.
- Truncated results (any tool) carry a `truncate_token=...`; use `get_truncated` \
with it to keep reading (chains through truncations).
- A long tool result is stored whole and trimmed on each request with a \
`document_id=...` marker; read the rest with `read_document` (line window) or \
`search_document` (regex), no re-run.
### Commands
- Prefer `run_argv` / `run_argv_batch` (argv lists, no shell) over `bash -c` or similar.
@@ -84,7 +85,7 @@ for ordered pipelines (`pipe_out` / `mix_stderr` as needed).
(GET/POST/PUT/DELETE) from this host for localhost/LAN, APIs, custom headers, and \
status/redirect inspection.
- `offset` (0-based) skips leading body characters to page through a large body; \
truncated bodies resume with `get_truncated` and are not re-requested.
a long body is stored whole, so its `document_id` reads the rest with no new request.
- Set `user_agent` (or a User-Agent header) on `fetch_local` when the remote expects a \
specific client; otherwise a small default is used. Never rely on shell to spoof identity.
- Private/loopback/LAN hosts need an explicit human policy allow (not skipped by YOLO); \
@@ -151,6 +152,11 @@ class AgentConfig(Struct, omit_defaults=True):
# into the agent system prompt when tools are on. false = ignore server text.
mcp_instructions: bool = True
max_tool_result_chars: int = 32_000
# Storage ceiling for one tool result kept whole in its message. The wire cap
# above trims a result on the request copy and names a document for the stored
# remainder; a result above this ceiling keeps its head behind a notice saying
# how much was dropped (the document cannot serve that tail).
hard_max_tool_result_chars: int = 2_000_000
parallel_tools: bool = True
confirm_destructive: bool = True
path_denylist: list[str] = field(default_factory=list)
@@ -284,7 +290,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.
+49 -6
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
@@ -176,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.
@@ -189,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)
@@ -542,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,
*,
@@ -556,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)
@@ -565,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()
@@ -604,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,
+21 -13
View File
@@ -4,7 +4,8 @@ A grant is one ``path → mode`` pair; its *source* is structural:
- :attr:`WorkspacePolicy.config_allow` — static TOML pre-allow (never prompts),
- :attr:`SessionState.access_grants` — human-approved for this session,
- :attr:`WorkspacePolicy.yolo_allow` — ``--yes`` / YOLO auto-approval, process only.
- :attr:`WorkspacePolicy.yolo_allow` — process only: a host that approved
without keeping the grant (``--yes`` does **not** answer this prompt).
``resolve_path`` consults all three. Grants are a policy gate for path-resolving
tools, **not** a sandbox: an ``exec`` grant (or any command) can reach the rest
@@ -16,7 +17,7 @@ from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple
from plyngent.agent import ToolTag, tool
from plyngent.agent import Denial, ToolTag, tool
from plyngent.tools.context import get_session, require_session
from plyngent.tools.workspace import (
AccessMode,
@@ -51,7 +52,7 @@ class AccessDecision(NamedTuple):
persist: bool = True
type DirectoryAccessConfirmHook = Callable[[Path, AccessMode, str, float], AccessDecision | str | None]
type DirectoryAccessConfirmHook = Callable[[Path, AccessMode, str, float], AccessDecision | str | Denial | None]
def get_directory_access_confirm_hook(instance: InstanceState | None = None) -> DirectoryAccessConfirmHook | None:
@@ -61,9 +62,9 @@ def get_directory_access_confirm_hook(instance: InstanceState | None = None) ->
if hook is None or not callable(hook):
return None
def _as_hook(path: Path, mode: AccessMode, reason: str, timeout: float) -> AccessDecision | str | None:
def _as_hook(path: Path, mode: AccessMode, reason: str, timeout: float) -> AccessDecision | str | Denial | None:
result: object = hook(path, mode, reason, timeout)
if isinstance(result, AccessDecision | str):
if isinstance(result, AccessDecision | str | Denial):
return result
return None
@@ -231,8 +232,12 @@ def _precheck_target(target: Path, requested: AccessMode) -> str | None:
return None
def _apply_decision(target: Path, decision: AccessDecision | str | None, timeout: float) -> str:
"""Record an approved grant or format the denial for the model."""
def _apply_decision(target: Path, decision: AccessDecision | str | Denial | None) -> str:
"""Record an approved grant or format the denial for the model.
The prompt says whether the human declined or the clock ran out, so a timeout
is never reported as a decision.
"""
if isinstance(decision, AccessDecision):
if decision.persist and get_session() is not None:
_ = grant_session_access(target, decision.mode)
@@ -244,9 +249,10 @@ def _apply_decision(target: Path, decision: AccessDecision | str | None, timeout
f"access granted: {target} ({decision.mode.name.lower()}, {scope})\n"
"note: the path denylist still applies; the human can revoke with /grants"
)
if isinstance(decision, str) and decision.strip():
return f"error: access to {target} denied by user; user comment: {decision.strip()}"
return f"error: access to {target} denied (declined or timed out after {timeout:g}s)"
denial = Denial.from_answer(decision)
text = f"error: access to {target} denied ({denial.clause()})"
comment = denial.user_comment
return f"{text}; user comment: {comment}" if comment else text
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.SESSION_STATE)
@@ -257,8 +263,10 @@ async def request_directory_access(path: str, reason: str = "", mode: str = "rea
workspace root``, passing the ``mode`` that error names. ``mode`` is ``read``
(inspect files), ``write`` (also modify files) or ``exec`` (also use it as
``run_argv`` / PTY cwd); the human may approve a different level. Approval
lasts for the session (or for this process when ``--yes`` auto-approves).
Paths matching the configured denylist can never be granted.
lasts for the session (or for this process when the host answers without
persisting); ``--yes`` / YOLO mode does not answer this prompt, and a
non-interactive run denies. Paths matching the configured denylist can never
be granted.
"""
requested = parse_access_mode(mode)
if requested is None:
@@ -278,7 +286,7 @@ async def request_directory_access(path: str, reason: str = "", mode: str = "rea
decision = hook(target, requested, reason, timeout)
except Exception as exc: # noqa: BLE001 — surface confirm failures to the model
return f"error: access confirm for {target} failed: {exc}"
return _apply_decision(target, decision, timeout)
return _apply_decision(target, decision)
ACCESS_TOOLS = [
+29
View File
@@ -14,6 +14,7 @@ if TYPE_CHECKING:
from pathlib import Path
from plyngent.agent.todo_stack import TodoStack
from plyngent.agent.tools import Approval
from plyngent.skills import SkillStore
from plyngent.tools.workspace import AccessMode, WorkspacePolicy
@@ -150,6 +151,34 @@ def bind_session(state: SessionState | None) -> Generator[SessionState | None]:
_read_only: ContextVar[bool] = ContextVar("plyngent_read_only_context", default=False)
# Approvals a handler records for the call it is answering. The registry binds a
# fresh list around one call and drains it into the result's first line.
_approvals: ContextVar[list[Approval] | None] = ContextVar("plyngent_tool_approvals", default=None)
@contextmanager
def collect_approvals() -> Generator[list[Approval]]:
"""Collect what the running tool call records about how it was approved."""
sink: list[Approval] = []
token = _approvals.set(sink)
try:
yield sink
finally:
_approvals.reset(token)
def record_approval(approval: Approval) -> None:
"""Record how the running call was approved (no-op outside a tool call).
A handler that asks the human for something policy-level — a denylisted
command, a skill write, a private host — says so here, so the model reads the
answer in the result it is about to see. The registry turns the records into
the single ``[approval]`` line that leads the tool result.
"""
sink = _approvals.get()
if sink is not None:
sink.append(approval)
@contextmanager
def read_only_context() -> Generator[None]:
+128
View File
@@ -0,0 +1,128 @@
"""Documents: addressable views over tool-result messages.
A tool result is stored whole in its own ``ToolChatMessage``. When the agent's
request-prep pass caps a long result, the marker it leaves names a
``document_id`` (the message's ``tool_call_id``); the model reads the rest with
the document tools. The body stays the message itself, so a document survives a
process restart with the session that holds it.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from msgspec import UNSET
from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, ToolChatMessage
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
@dataclass(frozen=True, slots=True)
class Document:
"""One tool-result message exposed as a document."""
document_id: str
text: str
tool_name: str | None
index: int
@property
def chars(self) -> int:
return len(self.text)
@property
def lines(self) -> int:
return len(self.text.splitlines())
def document_marker(document_id: str, shown: int, total: int, *, stored_cut: bool = False) -> str:
"""The request-prep cap hint naming the document that holds the remainder.
*total* counts what the stored message holds, so it is the end of what the
document can serve. ``stored_cut`` says the storage ceiling already dropped a
tail, which the document cannot serve either.
"""
cut = (
" The stored body was itself cut at the storage ceiling, so part of the output is not in the document."
if stored_cut
else ""
)
return (
f"\n[Truncated: {shown} of {total} chars shown.{cut} document_id={document_id}. "
f"Continue with read_document(document_id, offset=...) or "
f"search_document(document_id, pattern=...).]"
)
def _tool_names(messages: Sequence[AnyChatMessage]) -> dict[str, str]:
"""Map each tool-call id to the tool name the assistant asked for."""
names: dict[str, str] = {}
for message in messages:
if not isinstance(message, AssistantChatMessage):
continue
tool_calls = message.tool_calls
if tool_calls is UNSET or not tool_calls:
continue
for call in tool_calls:
function = getattr(call, "function", None)
name = getattr(function, "name", None) if function is not None else None
call_id = getattr(call, "id", None)
if isinstance(call_id, str) and isinstance(name, str):
names[call_id] = name
return names
class DocumentStore:
"""Resolve document ids to the tool-result messages that carry their body.
*resolver* returns the live message history (the agent's own list), so the
store reflects the session as it grows and reads whatever a resume reloaded
from the durable store.
"""
_resolver: Callable[[], Sequence[AnyChatMessage]]
def __init__(self, resolver: Callable[[], Sequence[AnyChatMessage]]) -> None:
self._resolver = resolver
def _documents(self) -> list[Document]:
messages = self._resolver()
names = _tool_names(messages)
documents: list[Document] = []
for message in messages:
if not isinstance(message, ToolChatMessage):
continue
documents.append(
Document(
document_id=message.tool_call_id,
text=message.content,
tool_name=names.get(message.tool_call_id),
index=len(documents),
)
)
return documents
def get(self, document_id: str) -> Document | None:
for document in self._documents():
if document.document_id == document_id:
return document
return None
def list(self) -> list[Document]:
return self._documents()
def get_document_store() -> DocumentStore | None:
"""The host-bound document store for the current tool context, if any."""
from plyngent.tools.context import get_instance
instance = get_instance()
if instance is None:
return None
store = instance.extras.get("documents")
return store if isinstance(store, DocumentStore) else None
+8
View File
@@ -1,5 +1,9 @@
from plyngent.tools.temp_workspace import new_temporary_workspace as new_temporary_workspace
from .document import document_info as document_info
from .document import list_documents as list_documents
from .document import read_document as read_document
from .document import search_document as search_document
from .edit_lineno import edit_lineno as edit_lineno
from .edit_replace import edit_replace as edit_replace
from .fs_ops import copy_path as copy_path
@@ -29,4 +33,8 @@ FILE_TOOLS = [
delete_path,
new_temporary_workspace,
get_truncated,
read_document,
search_document,
document_info,
list_documents,
]
+217
View File
@@ -0,0 +1,217 @@
"""Document tools: read and search the tool results the request-prep pass capped.
A long tool result is stored whole in its tool message and capped on the wire
with a ``document_id`` marker. These tools resolve that id back to the message
body, so reading and searching a capped result need no re-run of the tool.
"""
from __future__ import annotations
import re
from plyngent.agent import ToolTag, tool
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
from plyngent.tools.documents import Document, get_document_store
from plyngent.tools.file.read import format_with_lineno
DEFAULT_MAX_MATCHES = 100
DEFAULT_LINE_PREVIEW = 200
_MISSING_STORE = "error: documents are in scope only while a chat session holds tool results"
_UNKNOWN = "error: unknown document {document_id!r} (see list_documents)"
def _resolve(document_id: str) -> Document | str:
"""The document for *document_id*, or a model-facing error string."""
store = get_document_store()
if store is None:
return _MISSING_STORE
document = store.get(document_id)
if document is None:
return _UNKNOWN.format(document_id=document_id)
return document
def _cap_body(body: str, max_chars: int) -> tuple[str, bool]:
if max_chars < 1 or len(body) <= max_chars:
return body, False
return body[:max_chars], True
def _preview(line: str) -> str:
return line if len(line) <= DEFAULT_LINE_PREVIEW else line[:DEFAULT_LINE_PREVIEW] + "…"
def _compile_pattern(pattern: str, *, case_insensitive: bool) -> re.Pattern[str] | str:
if not pattern:
return "error: pattern must not be empty"
flags = re.MULTILINE
if case_insensitive:
flags |= re.IGNORECASE
try:
return re.compile(pattern, flags)
except re.error as exc:
return f"error: invalid regex: {exc}"
def _collect_hits(lines: list[str], regex: re.Pattern[str], max_matches: int) -> list[str]:
hits: list[str] = []
for number, line in enumerate(lines, start=1):
if regex.search(line) is None:
continue
hits.append(f"L{number}: {_preview(line)}")
if len(hits) >= max_matches:
break
return hits
def _search_with_context(
lines: list[str],
regex: re.Pattern[str],
max_matches: int,
context_lines: int,
) -> str:
matches = [number for number, line in enumerate(lines, start=1) if regex.search(line) is not None]
if not matches:
return "(no matches)"
truncated = len(matches) > max_matches
ranges: list[tuple[int, int]] = []
for number in matches[:max_matches]:
low = max(1, number - context_lines)
high = min(len(lines), number + context_lines)
if ranges and low <= ranges[-1][1] + 1:
ranges[-1] = (ranges[-1][0], max(ranges[-1][1], high))
else:
ranges.append((low, high))
blocks = [
"\n".join(f"L{number}: {_preview(lines[number - 1])}" for number in range(low, high + 1))
for low, high in ranges
]
body = "\n--\n".join(blocks)
if truncated:
body += f"\n...[truncated at {max_matches} matches]"
return body
def _search(text: str, regex: re.Pattern[str], max_matches: int, context_lines: int) -> str:
lines = text.splitlines()
if context_lines > 0:
return _search_with_context(lines, regex, max_matches, context_lines)
hits = _collect_hits(lines, regex, max_matches)
if not hits:
return "(no matches)"
body = "\n".join(hits)
if len(hits) >= max_matches:
body += f"\n...[truncated at {max_matches} matches]"
return body
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def read_document(
document_id: str,
*,
offset: int = 0,
limit: int | None = None,
with_lineno: bool = False,
max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
) -> str:
"""Read a capped tool result as a document, one window at a time.
A long tool result is stored whole in its tool message and capped on the wire
with a ``document_id`` marker; pass that id here to read past the cap.
``offset`` is a 0-based line start (0 = first line), ``limit`` a line count
(None = the rest), and ``with_lineno`` prefixes each line with its 1-based
number (`` N|…``). When the window is cut by ``max_chars`` the reply names
the next ``offset`` to send. ``list_documents`` lists the session's ids.
"""
document = _resolve(document_id)
if isinstance(document, str):
return document
if offset < 0:
return "error: offset must be >= 0"
lines = document.text.splitlines(keepends=True)
if offset >= len(lines):
return ""
end = len(lines) if limit is None else min(len(lines), offset + limit)
slice_lines = lines[offset:end]
body = format_with_lineno(slice_lines, start_lineno=offset + 1) if with_lineno else "".join(slice_lines)
body, cut = _cap_body(body, max_chars)
shown_lines = max(1, body.count("\n")) if cut else len(slice_lines)
next_offset = offset + shown_lines
if next_offset < len(lines):
separator = "" if body.endswith("\n") else "\n"
body = f"{body}{separator}[... more: next offset={next_offset}]"
if with_lineno:
return body
return f"L{offset + 1}-{next_offset}\n{body}"
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def search_document(
document_id: str,
pattern: str,
*,
case_insensitive: bool = False,
max_matches: int = DEFAULT_MAX_MATCHES,
context_lines: int = 0,
) -> str:
"""Search a document's lines with a Python ``re`` regex, per document.
Like ``regex_files`` but over one tool result. ``pattern`` is a Python regex
matched per line (``re.MULTILINE``), not a glob. Returns ``L{line}: {content}``
hits, long lines clipped with ``…``, capped at ``max_matches`` (a
``...[truncated at N matches]`` marker follows a cut). ``context_lines`` adds
that many lines around each hit, blocks joined with ``--``. ``list_documents``
lists the session's ids.
"""
if max_matches < 1:
return "error: max_matches must be >= 1"
if context_lines < 0:
return "error: context_lines must be >= 0"
document = _resolve(document_id)
if isinstance(document, str):
return document
regex = _compile_pattern(pattern, case_insensitive=case_insensitive)
if isinstance(regex, str):
return regex
return _search(document.text, regex, max_matches, context_lines)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def document_info(document_id: str) -> str:
"""Describe one document: source tool, size, and first/last line previews."""
document = _resolve(document_id)
if isinstance(document, str):
return document
lines = document.text.splitlines()
first = _preview(lines[0]) if lines else ""
last = _preview(lines[-1]) if lines else ""
return "\n".join(
[
f"document_id={document.document_id}",
f"tool={document.tool_name or '(unknown)'}",
f"chars={document.chars}",
f"lines={document.lines}",
f"first={first}",
f"last={last}",
]
)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def list_documents() -> str:
"""List the session's documents (capped tool results) with their ids and sizes."""
store = get_document_store()
if store is None:
return _MISSING_STORE
documents = store.list()
if not documents:
return "(no documents)"
rows: list[str] = []
for document in documents:
first_line = document.text.splitlines()[0] if document.text else ""
rows.append(
f"{document.index}. document_id={document.document_id} tool={document.tool_name or '(unknown)'} "
f"chars={document.chars} lines={document.lines} | {_preview(first_line)}"
)
return "\n".join(rows)
+2 -2
View File
@@ -11,7 +11,7 @@ from plyngent.tools.workspace import AccessMode, resolve_path
_LINENO_WIDTH = 6
def _format_with_lineno(lines: list[str], *, start_lineno: int) -> str:
def format_with_lineno(lines: list[str], *, start_lineno: int) -> str:
"""Prefix each line with a 1-based absolute line number (``edit_lineno`` style)."""
out: list[str] = []
for index, line in enumerate(lines):
@@ -94,7 +94,7 @@ async def read_file(
if start >= len(lines):
return ""
slice_lines = lines[start:end]
body = _format_with_lineno(slice_lines, start_lineno=start + 1) if with_lineno else "".join(slice_lines)
body = format_with_lineno(slice_lines, start_lineno=start + 1) if with_lineno else "".join(slice_lines)
char_start = len("".join(lines[:start]))
if max_chars >= 1:
body, token = truncate_with_token(
+9 -15
View File
@@ -9,7 +9,6 @@ from plyngent.tools.net.client import FetchResult, http_fetch
from plyngent.tools.net.policy import (
DEFAULT_MAX_BODY_CHARS_IN,
DEFAULT_MAX_BYTES,
DEFAULT_MAX_CHARS,
DEFAULT_MAX_REDIRECTS,
DEFAULT_TIMEOUT_SECONDS,
FetchPolicyError,
@@ -18,7 +17,6 @@ from plyngent.tools.net.policy import (
parse_fetch_url,
soft_confirm_reason,
)
from plyngent.tools.truncate_token import truncate_to_memory
from plyngent.tools.workspace import image_input_refusal
@@ -35,15 +33,13 @@ def format_fetch_result(
security: str,
warnings: list[str],
body_kind: str,
max_chars: int,
offset: int = 0,
) -> str:
start = max(0, offset)
# The body is already in hand, so the remainder is kept in memory rather than
# re-requested: resuming a fetch must not cost another round trip (latency)
# nor re-hit the remote (rate limits / risk control).
body, token = truncate_to_memory(body_text[start:], max_chars)
char_truncated = token is not None
# The body is kept whole in the tool message (the request-prep pass trims it
# on the wire and names its document), so resuming reads the message rather
# than re-hitting the remote.
body = body_text[start:]
warn_line = "; ".join(warnings) if warnings else ""
parts = [
f"status={status}",
@@ -52,7 +48,7 @@ def format_fetch_result(
f"content_type={content_type}",
f"body_kind={body_kind}",
f"bytes={body_bytes}",
f"truncated={'true' if truncated or char_truncated else 'false'}",
f"truncated={'true' if truncated else 'false'}",
f"redirects={redirects}",
f"security={security}",
]
@@ -73,19 +69,18 @@ async def fetch_local(
user_agent: str | None = None,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
max_bytes: int = DEFAULT_MAX_BYTES,
max_chars: int = DEFAULT_MAX_CHARS,
max_redirects: int = DEFAULT_MAX_REDIRECTS,
follow_redirects: bool = True,
allow_http_downgrade: bool = False,
offset: int = 0,
) -> str | ToolOutput:
"""HTTP request (GET/POST/PUT/DELETE); return status, metadata, and truncated body.
"""HTTP request (GET/POST/PUT/DELETE); return status, metadata, and the body.
Use for public docs/APIs or (after human policy allow) local/LAN servers.
``offset`` skips that many leading characters of the body (0-based), for
paging through a body larger than ``max_chars``; a body truncated by
``max_chars`` keeps its remainder in memory instead, resumable via its
``truncate_token`` without another request.
paging through a body the wire cap trimmed; a trimmed body keeps its
remainder in the tool message, so its ``document_id`` reads the rest with no
new request.
``user_agent`` sets User-Agent when provided and takes precedence over a
User-Agent entry in ``headers``. A headers-only User-Agent is kept as-is.
When both omit UA, a small default is used.
@@ -138,7 +133,6 @@ async def fetch_local(
security=result.security,
warnings=result.warnings,
body_kind=result.body_kind,
max_chars=max_chars,
offset=offset,
)
return _attach_image(text, result)
+9 -15
View File
@@ -13,8 +13,6 @@ import inspect
from typing import TYPE_CHECKING
from plyngent.agent.tools import ToolDefinition, ToolTag, schema_from_callable
from plyngent.tools.net.policy import DEFAULT_MAX_CHARS
from plyngent.tools.truncate_token import truncate_to_memory
if TYPE_CHECKING:
from plyngent.lmproto.deepseek.webtools import DeepseekWebToolsClient
@@ -27,31 +25,28 @@ def format_page_result(
title: str,
url: str,
content: str,
max_chars: int,
offset: int = 0,
) -> str:
"""Render a fetched page the way the other structured tool results read."""
start = max(0, offset)
body, token = truncate_to_memory(content[start:], max_chars)
# The page is kept whole in the tool message (the request-prep pass trims it
# on the wire and names its document), so neither a re-fetch nor a charge
# follows a read of the rest.
body = content[start:]
parts = [
f"title={' '.join(title.split()) or '(untitled)'}",
f"url={url}",
f"chars={len(content)}",
f"truncated={'true' if token is not None else 'false'}",
"--- content ---",
body,
]
return "\n".join(parts)
def build_fetch_tool(
client: DeepseekWebToolsClient,
*,
default_max_chars: int = DEFAULT_MAX_CHARS,
) -> ToolDefinition:
def build_fetch_tool(client: DeepseekWebToolsClient) -> ToolDefinition:
"""A ``fetch`` definition backed by DeepSeek's Fetch API."""
async def fetch(url: str, offset: int = 0, max_chars: int = default_max_chars) -> str:
async def fetch(url: str, offset: int = 0) -> str:
"""Read a public web page as Markdown through DeepSeek's Fetch API.
Use for public articles and docs when clean Markdown is enough. The page
@@ -60,9 +55,9 @@ def build_fetch_tool(
when you need the raw HTTP status/headers. A successful fetch is billed
per request (failures are not).
``offset`` (0-based) skips leading characters of the content. A body cut
by ``max_chars`` keeps its remainder in memory, resumable via its
``truncate_token`` without another request (and without another charge).
``offset`` (0-based) skips leading characters of the content. The page is
kept whole in the tool message, so a trimmed ``document_id`` reads the
rest with no new request (and no new charge).
"""
try:
page = await client.fetch(url)
@@ -72,7 +67,6 @@ def build_fetch_tool(
title=page.title,
url=url,
content=page.content,
max_chars=max_chars,
offset=offset,
)
+19 -6
View File
@@ -10,10 +10,11 @@ from plyngent.tools.workspace import WorkspaceError, require_bound_instance
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from plyngent.agent.tools import Denial
from plyngent.tools.context import InstanceState
# (host, port, url, timeout_seconds) -> True to allow for this process/instance.
type FetchPolicyConfirmHook = Callable[[str, int, str, float], bool]
type FetchPolicyConfirmHook = Callable[[str, int, str, float], bool | Denial]
def get_private_grants(instance: InstanceState | None = None) -> set[str]:
@@ -48,8 +49,14 @@ def get_fetch_policy_confirm_hook(instance: InstanceState | None = None) -> Fetc
if hook is None or not callable(hook):
return None
def _as_hook(host: str, port: int, url: str, timeout_seconds: float) -> bool:
return bool(hook(host, port, url, timeout_seconds))
def _as_hook(host: str, port: int, url: str, timeout_seconds: float) -> bool | Denial:
from plyngent.agent import Denial as DenialAnswer
answer: object = hook(host, port, url, timeout_seconds)
# Pass the reason through: only an explicit True allows.
if answer is True or isinstance(answer, DenialAnswer):
return answer
return False
return _as_hook
@@ -89,7 +96,11 @@ async def ensure_host_allowed(
raise FetchPolicyError(msg)
# PRIVATE
from plyngent.agent import Approval, Denial
from plyngent.tools.context import record_approval
if has_private_grant(parsed.host, parsed.port, instance=inst):
record_approval(Approval("grant", "process"))
return
from plyngent.tools.workspace import get_policy_confirm_timeout
@@ -105,17 +116,19 @@ async def ensure_host_allowed(
)
raise FetchPolicyError(msg)
try:
allowed = bool(hook(parsed.host, parsed.port, parsed.url, timeout))
answer = hook(parsed.host, parsed.port, parsed.url, timeout)
except Exception as exc:
msg = f"fetch_local blocked private/loopback host {parsed.host}:{parsed.port} (confirm failed: {exc})"
raise FetchPolicyError(msg) from exc
if not allowed:
if answer is not True:
denial = Denial.from_answer(answer)
msg = (
f"fetch_local blocked private/loopback host {parsed.host}:{parsed.port} "
f"(user declined or timed out after {timeout:g}s; not skipped by YOLO)"
f"({assessment.reason}; {denial.clause()}; not skipped by YOLO)"
)
raise FetchPolicyError(msg)
_ = grant_private_host(parsed.host, parsed.port, instance=inst)
record_approval(Approval("user", "process"))
def format_grant_preview(host: str, port: int, url: str) -> Sequence[str]:
-1
View File
@@ -36,7 +36,6 @@ DEFAULT_USER_AGENT = "plyngent-fetch/0.2"
DEFAULT_MAX_REDIRECTS = 5
DEFAULT_TIMEOUT_SECONDS = 30.0
DEFAULT_MAX_BYTES = 1_000_000
DEFAULT_MAX_CHARS = 32_000
DEFAULT_MAX_BODY_CHARS_IN = 256_000 # request body size limit (tool arg)
# Cloud / link-local style targets never granted via policy UI.
+2 -19
View File
@@ -18,26 +18,9 @@ from plyngent.tools.workspace import (
)
DEFAULT_TIMEOUT_SECONDS = 30
DEFAULT_MAX_OUTPUT_CHARS = 32_000
DEFAULT_MAX_BATCH_STEPS = 20
def truncate_output(text: str, label: str, max_chars: int = DEFAULT_MAX_OUTPUT_CHARS) -> str:
"""Cap a per-stream output block; embed a truncate token when cut.
``label`` (stdout/stderr/batch) is kept for call-site clarity but the
marker itself is the uniform ``[Truncated (N chars max.; M omitted).
truncate_token=...]`` (no label), matching every other truncation site.
Returns ``text`` unchanged when it fits.
"""
del label
if max_chars < 1 or len(text) <= max_chars:
return text
from plyngent.tools.truncate_token import truncate_generic
return truncate_generic(text, max_chars)
def format_command_result(
*,
returncode: int | None,
@@ -53,9 +36,9 @@ def format_command_result(
f"cwd={workdir_display}",
f"cmd={shlex.join(command)}",
"--- stdout ---",
truncate_output(stdout, "stdout"),
stdout,
"--- stderr ---",
truncate_output(stderr, "stderr"),
stderr,
]
return "\n".join(parts)
+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)))
+9 -8
View File
@@ -9,13 +9,14 @@ from plyngent.tools.workspace import WorkspaceError
from .command_exec import (
DEFAULT_MAX_BATCH_STEPS,
DEFAULT_MAX_OUTPUT_CHARS,
DEFAULT_TIMEOUT_SECONDS,
CommandStepResult,
execute_argv,
truncate_output,
)
# Execution budget: stop the batch once accumulated output crosses this factor
# of the base budget, so a runaway batch cannot keep spawning huge-output steps.
BATCH_OUTPUT_BUDGET_CHARS = 32_000
BATCH_OUTPUT_SOFT_CAP_FACTOR = 4
@@ -102,9 +103,9 @@ def _format_step(index: int, result: CommandStepResult, *, pipe_out: bool) -> st
f"pipe_out={'true' if pipe_out else 'false'}",
f"mix_stderr={'true' if result.mix_stderr else 'false'}",
"--- stdout ---",
truncate_output(result.stdout, "stdout"),
result.stdout,
"--- stderr ---",
truncate_output(result.stderr, "stderr"),
result.stderr,
]
)
@@ -147,7 +148,7 @@ async def _run_batch_steps(
effective_stop = stop_on_error if step["stop_on_error"] is None else bool(step["stop_on_error"])
if failed and effective_stop:
return results, True
if total_chars > DEFAULT_MAX_OUTPUT_CHARS * BATCH_OUTPUT_SOFT_CAP_FACTOR:
if total_chars > BATCH_OUTPUT_BUDGET_CHARS * BATCH_OUTPUT_SOFT_CAP_FACTOR:
return results, True
del index # used for clarity in loop only
@@ -178,7 +179,9 @@ async def run_argv_batch(
}
``pipe_out`` is set on the **provider** step. Last-step ``pipe_out`` is an error.
Default ``stop_on_error`` is true. Max 20 steps; total output budget 32k chars.
Default ``stop_on_error`` is true. Max 20 steps; the batch stops early once
accumulated output passes the run budget (128k chars). Each step's output is
kept whole for the model; a long result carries a ``document_id`` to read on.
"""
try:
raw_steps = _normalize_steps(steps)
@@ -195,8 +198,6 @@ async def run_argv_batch(
body = "\n".join(
_format_step(i, result, pipe_out=bool(step["pipe_out"])) for i, (step, result) in enumerate(results)
)
if len(body) > DEFAULT_MAX_OUTPUT_CHARS:
body = truncate_output(body, "batch")
last_exit = results[-1][1].exit_code if results else ""
return "\n".join(
[
+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:
+15 -4
View File
@@ -18,7 +18,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, cast
from plyngent.agent import ToolTag, tool
from plyngent.agent import Approval, Denial, ToolTag, tool
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
from plyngent.skills import (
SKILL_FILE,
@@ -30,6 +30,7 @@ from plyngent.skills import (
resolve_skill_file,
validate_skill_name,
)
from plyngent.tools.context import record_approval
from plyngent.tools.file.read import read_file as _read_file
from plyngent.tools.file.regex_files import DEFAULT_MAX_MATCHES
from plyngent.tools.file.regex_files import regex_files as _regex_files
@@ -260,9 +261,11 @@ class SkillWriteDecision:
# True keeps the grant for the rest of this chat session (the standing
# alternative is ``[skills].allow_write`` in the config).
session: bool = False
# Why a denial: declined, timed out, cancelled, or nobody could be asked.
denial: Denial | None = None
type SkillWriteConfirmHook = Callable[[Path, str, str, float], SkillWriteDecision | bool | None]
type SkillWriteConfirmHook = Callable[[Path, str, str, float], SkillWriteDecision | Denial | bool | None]
@dataclass(frozen=True, slots=True)
@@ -328,9 +331,12 @@ def _prompt_write(
return f"error: skill write confirm failed: {exc}"
allowed = decision is True or (isinstance(decision, SkillWriteDecision) and decision.allow)
if not allowed:
return f"error: skill write to {skill_dir} denied (declined or timed out after {policy.timeout:g}s)"
if isinstance(decision, SkillWriteDecision) and decision.session and session is not None:
denial = decision.denial if isinstance(decision, SkillWriteDecision) else None
return f"error: skill write to {skill_dir} denied ({(denial or Denial.from_answer(decision)).clause()})"
keys_session = isinstance(decision, SkillWriteDecision) and decision.session
if keys_session and session is not None:
session.skill_write_roots.add(skill_dir)
record_approval(Approval("user", "session" if keys_session else "once"))
return None
@@ -342,6 +348,9 @@ def _authorize_write(skill_dir: Path, target: Path, *, action: str, detail: str)
checked first and always wins; then a session grant, then
``[skills].allow_write``, then the host's confirm hook. Without a hook the
write is denied rather than silently allowed.
Each allow that skipped the prompt is recorded too, so the result says why
nobody was asked: ``grant (session)`` or ``config (policy)``.
"""
denied = _denied_by_policy(target, skill_dir)
if denied is not None:
@@ -350,6 +359,7 @@ def _authorize_write(skill_dir: Path, target: Path, *, action: str, detail: str)
session = get_session()
if session is not None and skill_dir in session.skill_write_roots:
record_approval(Approval("grant", "session"))
return None
policy = get_skill_write_policy()
if policy is None or (policy.confirm is None and not policy.allow_all):
@@ -358,6 +368,7 @@ def _authorize_write(skill_dir: Path, target: Path, *, action: str, detail: str)
"set [skills].allow_write = true to allow skill writes"
)
if policy.allow_all:
record_approval(Approval("config", "policy"))
return None
return _prompt_write(skill_dir, action=action, detail=detail, policy=policy, session=session)
+17 -29
View File
@@ -1,24 +1,21 @@
"""Truncate-token cursors for resuming truncated tool results.
"""Truncate-token cursors for resuming truncated reads.
Every truncation site (the agent loop's generic tool-result cap,
``run_argv`` / ``run_argv_batch`` per-stream caps, ``read_file``,
``fetch`` / ``fetch_local``, and request-time compact shrinks) appends the same
compact marker when output is cut short::
Two sources use a truncate token:
[Truncated (32000 chars max.; 500 omitted). truncate_token=...]
- ``read_file`` caps a slice with a ``file`` token; the remainder is re-read from
disk by ``location`` + ``offset``, so it survives for as long as the file does
(and ``numbered`` resumes the ``N|`` view for ``edit_lineno``).
- A user ``@path`` mention inlines a file with a ``memory`` token. The mention
carries text into a user turn rather than a tool message, so the remainder
lives in a short-lived in-memory store that is cleared when the agent process
exits.
The model passes the token to ``get_truncated`` to read the next chunk without
re-requesting the whole source or raising limits. Tokens chain: each truncated
chunk carries a fresh token, so ``get_truncated`` keeps resuming until the
source is exhausted (then no marker is emitted).
Tool results no longer use this module. A result is stored whole in its own
message, and the request-prep pass trims the request copy with a marker naming a
document (``read_document`` / ``search_document``; see ``tools/documents.py``).
A ``file`` remainder is re-read from disk by ``location`` + ``offset``, so it
survives for as long as the file does. Everything else — arbitrary tool output
(run_argv stdout, todo renders, …) and ``fetch`` / ``fetch_local`` bodies — have
no source worth re-running (a re-fetch costs a round trip and re-hits the remote),
so its remainder lives in a short-lived in-memory store (``kind="memory"``) that is
forgotten when the agent process exits; the model must re-run the tool after
that.
Either way the model passes the token to ``get_truncated`` to read the next
chunk without re-requesting the whole source.
"""
from __future__ import annotations
@@ -141,7 +138,9 @@ def truncate_to_memory(text: str, max_chars: int) -> tuple[str, TruncateToken |
Nothing is stored when the text fits, so a caller that only needs the
bounded text does not pay for a remainder. Returns ``(bounded_text, token)``
where ``token`` is ``None`` when the source is exhausted.
where ``token`` is ``None`` when the source is exhausted. A user ``@path``
mention is the caller: its inlined text has no re-readable source, so the
remainder waits in memory until the process exits.
"""
if max_chars < 1 or len(text) <= max_chars:
return text, None
@@ -155,14 +154,3 @@ def truncate_to_memory(text: str, max_chars: int) -> tuple[str, TruncateToken |
limit=max_chars,
total_len=len(text),
)
def truncate_generic(text: str, max_chars: int) -> str:
"""Cap arbitrary tool output; embed a memory truncate token (chainable).
The full remainder is kept in the in-memory store so ``get_truncated`` can
resume it. Returns ``text`` unchanged (no marker) when it fits within
``max_chars``.
"""
bounded, _ = truncate_to_memory(text, max_chars)
return bounded
+4 -12
View File
@@ -8,7 +8,6 @@ if TYPE_CHECKING:
from pathlib import Path
DEFAULT_TIMEOUT_SECONDS = 30.0
DEFAULT_MAX_OUTPUT_CHARS = 64_000
def is_git_repo(root: Path) -> bool:
@@ -32,13 +31,6 @@ class _GitResult:
self.returncode = returncode
def _truncate(text: str) -> str:
if len(text) <= DEFAULT_MAX_OUTPUT_CHARS:
return text
omitted = len(text) - DEFAULT_MAX_OUTPUT_CHARS
return f"{text[:DEFAULT_MAX_OUTPUT_CHARS]}\n...[truncated {omitted} characters]"
def _run_git(root: Path, args: list[str], *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> _GitResult:
argv = ["git", "-c", "color.ui=false", *args]
try:
@@ -57,12 +49,12 @@ def _run_git(root: Path, args: list[str], *, timeout: float = DEFAULT_TIMEOUT_SE
except OSError as exc:
return _GitResult(ok=False, stdout="", stderr=str(exc), returncode=1)
stdout = _truncate(completed.stdout or "")
stderr = _truncate(completed.stderr or "")
# Output is kept whole; the request-prep pass trims it on the wire and names
# its document, so a long log/diff reads on rather than re-running git.
return _GitResult(
ok=completed.returncode == 0,
stdout=stdout,
stderr=stderr,
stdout=completed.stdout or "",
stderr=completed.stderr or "",
returncode=completed.returncode,
)
+26 -9
View File
@@ -10,6 +10,7 @@ from plyngent.tools.command_scan import basename, unwrap_command
if TYPE_CHECKING:
from collections.abc import Callable, Mapping, Sequence
from plyngent.agent.tools import Denial
from plyngent.tools.context import InstanceState, SessionState
DEFAULT_COMMAND_DENYLIST: frozenset[str] = frozenset(
@@ -89,7 +90,7 @@ def mode_covers(granted: AccessMode, required: AccessMode) -> bool:
# Hook: (basename, argv, timeout_seconds) -> True allow for this session basename.
type PolicyConfirmHook = Callable[[str, Sequence[str], float], bool]
type PolicyConfirmHook = Callable[[str, Sequence[str], float], bool | Denial]
class WorkspaceError(ValueError):
@@ -114,7 +115,8 @@ class WorkspacePolicy:
policy_confirm_timeout_seconds: float = DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS
# Static TOML pre-allow (resolved path → mode); never prompts.
config_allow: dict[Path, AccessMode] = field(default_factory=dict)
# Process-scoped YOLO / --yes grants (resolved path → mode); never persisted.
# Process-scoped grants (resolved path → mode): a host answer kept only for
# this process. YOLO mode never fills it; nothing is auto-approved here.
yolo_allow: dict[Path, AccessMode] = field(default_factory=dict)
# Host-contributed read-only roots (skill directories): every path tool may
# read them, nothing may write them. Not a grant, so they stay out of
@@ -445,9 +447,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.
@@ -557,7 +567,14 @@ def check_command_allowed(argv: list[str]) -> None:
Denylisted programs are not hard-rejected when a policy confirm hook is
installed: the human is asked (with a timeout; default deny). Session grants
skip re-prompting for the same program. Independent of YOLO soft-confirm.
Either way the call records how it got past the denylist, so the tool result
says it: ``user (process)`` for this call's answer, ``grant (process)`` when
an earlier one already covers this program.
"""
from plyngent.agent import Approval, Denial
from plyngent.tools.context import record_approval
shape_error = argv_shape_error(argv)
if shape_error is not None:
msg = f"command {shape_error}"
@@ -569,22 +586,22 @@ def check_command_allowed(argv: list[str]) -> None:
for hit in match_command_denylist(argv, policy.command_denylist):
binary = hit.program
if binary in policy.policy_allowed_commands:
record_approval(Approval("grant", "process"))
continue
why = f": {hit.keyword!r}" if hit.keyword else ""
hook = policy.policy_confirm_hook
if hook is None:
msg = f"command denied by policy (basename {binary!r} is blocked{why})"
msg = f"command denied by policy (basename {binary!r} is blocked{why}; no policy confirm hook)"
raise WorkspaceError(msg)
timeout = policy.policy_confirm_timeout_seconds
try:
allowed = bool(hook(binary, list(argv), timeout))
answer = hook(binary, list(argv), timeout)
except Exception as exc:
msg = f"command denied by policy (basename {binary!r}; confirm failed: {exc})"
raise WorkspaceError(msg) from exc
if not allowed:
msg = (
f"command denied by policy (basename {binary!r} is blocked{why}; "
f"user declined or timed out after {timeout:g}s)"
)
if answer is not True:
denial = Denial.from_answer(answer)
msg = f"command denied by policy (basename {binary!r} is blocked{why}; {denial.clause()})"
raise WorkspaceError(msg)
policy.policy_allowed_commands.add(binary)
record_approval(Approval("user", "process"))
+49 -15
View File
@@ -1,27 +1,61 @@
from __future__ import annotations
from plyngent.agent.budget import estimate_message_chars, truncate_tool_result
from plyngent.agent.budget import (
cap_hard_tool_text,
cap_tool_message,
estimate_message_chars,
truncate_for_request,
)
from plyngent.lmproto.openai_compatible.model import ToolChatMessage, UserChatMessage
def test_truncate_tool_result_short() -> None:
assert truncate_tool_result("hello", 100) == "hello"
def test_cap_hard_tool_text_short_untouched() -> None:
assert cap_hard_tool_text("hello", 100) == "hello"
def test_truncate_tool_result_long() -> None:
text = "a" * 50
out = truncate_tool_result(text, 20)
assert "Truncated" in out
assert "20" in out
assert "30" in out
# Generic tool results now embed a memory token too (resumable via get_truncated).
assert "truncate_token=" in out
assert out.startswith(("a" * 10, "a" * 20)) # marker is budgeted
def test_cap_hard_tool_text_cuts_with_the_notice_up_front() -> None:
"""The notice leads: the wire cap sends the head, so a trailing one is lost."""
out = cap_hard_tool_text("a" * 50, 20)
head, _, body = out.partition("\n")
assert "20-char ceiling" in head
assert "30 chars beyond it were dropped and cannot be read back" in head
assert body == "a" * 20
def test_truncate_tool_result_fits_no_marker() -> None:
assert truncate_tool_result("tiny", 100) == "tiny"
assert "Truncated" not in truncate_tool_result("tiny", 100)
def test_cap_tool_message_names_the_document() -> None:
message = ToolChatMessage(content="a" * 50, tool_call_id="call-7")
capped = cap_tool_message(message, 20)
assert capped is not message
assert capped.tool_call_id == "call-7"
assert "document_id=call-7" in capped.content
assert capped.content.startswith("a")
assert "storage ceiling" not in capped.content
# The stored message keeps its body whole.
assert message.content == "a" * 50
def test_cap_tool_message_flags_a_body_the_ceiling_cut() -> None:
"""A cut body is not the whole output, so the marker must not imply it is."""
stored = cap_hard_tool_text("a" * 500, 100)
capped = cap_tool_message(ToolChatMessage(content=stored, tool_call_id="call-9"), 60)
assert "document_id=call-9" in capped.content
assert "cut at the storage ceiling" in capped.content
def test_cap_tool_message_short_untouched() -> None:
message = ToolChatMessage(content="tiny", tool_call_id="call-1")
assert cap_tool_message(message, 100) is message
def test_truncate_for_request_caps_only_tool_messages() -> None:
user = UserChatMessage(content="u" * 1000)
tool = ToolChatMessage(content="t" * 1000, tool_call_id="call-1")
out = truncate_for_request([user, tool], max_tool_result_chars=200)
assert out[0] is user
capped = out[1]
assert isinstance(capped, ToolChatMessage)
assert "document_id=call-1" in capped.content
assert len(capped.content) <= 200
def test_estimate_message_chars() -> None:
+2 -24
View File
@@ -8,7 +8,6 @@ from plyngent.agent.budget import (
compact_messages_for_request,
estimate_messages_tokens,
measure_messages_tokens,
truncate_tool_result,
)
from plyngent.agent.loop import run_chat_loop
from plyngent.lmproto.openai_compatible.model import (
@@ -33,27 +32,6 @@ if TYPE_CHECKING:
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
def test_truncate_tool_result_short() -> None:
assert truncate_tool_result("hello", 100) == "hello"
def test_truncate_tool_result_long() -> None:
text = "a" * 50
out = truncate_tool_result(text, 20)
assert "Truncated" in out
assert "20" in out
assert "30" in out
# Generic tool results now embed a memory token too (resumable via get_truncated).
assert "truncate_token=" in out
def test_truncate_tool_result_no_hint() -> None:
text = "a" * 50
out = truncate_tool_result(text, 20)
assert "Truncated" in out
assert "truncate_token=" in out
def test_compact_shrinks_old_tool_results() -> None:
messages: list[AnyChatMessage] = [
UserChatMessage(content="start"),
@@ -92,8 +70,8 @@ def test_compact_shrinks_old_tool_results() -> None:
assert isinstance(compacted[2], ToolChatMessage)
assert len(compacted[2].content) < original_len
assert "Truncated" in compacted[2].content
# Compact shrinks carry a token too (resumable via get_truncated).
assert "truncate_token=" in compacted[2].content
# A shrunk result names its document (read_document / search_document).
assert "document_id=1" in compacted[2].content
# Full history unchanged
assert isinstance(messages[2], ToolChatMessage)
assert len(messages[2].content) == original_len
+1 -1
View File
@@ -33,7 +33,7 @@ async def test_confirm_allow() -> None:
danger=classify_danger,
on_confirm=on_confirm,
)
assert await registry.execute("delete_path", '{"path": "a.txt"}') == "deleted a.txt"
assert await registry.execute("delete_path", '{"path": "a.txt"}') == "[approval] user (once)\ndeleted a.txt"
assert len(decisions) == 1
assert await registry.execute("read_file", '{"path": "a.txt"}') == "read a.txt"
assert len(decisions) == 1
+135 -7
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
@@ -942,7 +1065,7 @@ async def test_chat_agent_system_prompt_prepended() -> None:
async def test_tool_result_char_budget() -> None:
@tool(register=False)
def big() -> str:
return "x" * 100
return "x" * 2000
registry = ToolRegistry([big])
client = ScriptedClient(
@@ -970,16 +1093,21 @@ async def test_tool_result_char_budget() -> None:
model="m",
tools=registry,
stream=False,
max_tool_result_chars=20,
max_tool_result_chars=200,
parallel_tools=False,
)
]
from plyngent.lmproto.openai_compatible.model import ToolChatMessage
tool_msgs = [m for m in messages if isinstance(m, ToolChatMessage)]
assert len(tool_msgs) == 1
assert tool_msgs[0].content.startswith("x" * 20)
assert "Truncated" in tool_msgs[0].content
# The stored result stays whole; only the wire copy is capped, and it names
# the document that reads the rest.
stored = [m for m in messages if isinstance(m, ToolChatMessage)]
assert len(stored) == 1
assert stored[0].content == "x" * 2000
wire = next(m for m in client.calls[-1].messages if isinstance(m, ToolChatMessage))
assert wire.content.startswith("x")
assert "document_id=1" in wire.content
assert len(wire.content) < 2000
async def test_empty_completion_raises() -> None:
+1 -1
View File
@@ -253,5 +253,5 @@ async def test_a_write_holds_its_file_across_the_confirm() -> None:
await asyncio.sleep(0) # the second write waits for the file, not for the human
assert order == ["confirm write_a"]
answered.set()
assert await asyncio.gather(first, second) == ["a", "b"]
assert await asyncio.gather(first, second) == ["[approval] user (once)\na", "b"]
assert order == ["confirm write_a", "a", "b"]
+154
View File
@@ -0,0 +1,154 @@
"""The ``[approval]`` line: a confirmed call says how it got through."""
from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.agent import (
Approval,
BatchConfirmDecision,
ToolRegistry,
ToolTag,
format_approval,
most_specific_approval,
tool,
)
from plyngent.agent.budget import cap_tool_message
from plyngent.lmproto.openai_compatible.model import ToolChatMessage
from plyngent.tools.context import SessionState, bind_tool_context, record_approval
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from plyngent.agent import ToolConfirmRequest
def _danger(name: str, _args: Mapping[str, object]) -> str | None:
return f"mutate {name}"
def _allow(_name: str, _args: Mapping[str, object], _reason: str) -> bool:
return True
@tool(register=False)
async def quiet() -> str:
return "quiet"
@tool(register=False)
async def mutate() -> str:
return "ran"
async def test_a_call_that_needs_no_confirmation_carries_no_line() -> None:
"""The common case stays byte-for-byte what the tool returned."""
def danger(_name: str, _args: Mapping[str, object]) -> str | None:
return None
def on_confirm(_name: str, _args: Mapping[str, object], _reason: str) -> bool:
msg = "no prompt expected"
raise AssertionError(msg)
reg = ToolRegistry([quiet], danger=danger, on_confirm=on_confirm)
assert await reg.execute("quiet", "{}") == "quiet"
async def test_a_single_prompt_leads_the_result() -> None:
reg = ToolRegistry([mutate], danger=_danger, on_confirm=_allow)
assert await reg.execute("mutate", "{}") == "[approval] user (once)\nran"
async def test_yolo_mode_says_it_dropped_the_prompt() -> None:
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
async def yolo_ok() -> str:
return "y"
reg = ToolRegistry([yolo_ok], danger=_danger, on_confirm=_allow, yolo=True)
assert await reg.execute("yolo_ok", "{}") == "[approval] yolo (auto)\ny"
async def test_a_batch_answer_covers_its_round() -> None:
def on_batch(_requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
return BatchConfirmDecision(allowed=True)
reg = ToolRegistry([mutate], danger=_danger, on_confirm_batch=on_batch)
calls = [("mutate", "{}"), ("mutate", "{}")]
decisions = await reg.prepare_batch_confirm(calls)
results = [
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
]
assert results == ["[approval] user (round)\nran", "[approval] user (round)\nran"]
async def test_a_session_grant_says_nobody_was_asked_this_time() -> None:
@tool(tags=ToolTag.LOCAL | ToolTag.TRUSTABLE, register=False)
async def trust_me() -> str:
return "ok"
prompts: list[str] = []
def on_confirm(name: str, _args: Mapping[str, object], _reason: str) -> bool:
prompts.append(name)
return True
session = SessionState(session_id=1)
reg = ToolRegistry([trust_me], danger=_danger, on_confirm=on_confirm, session_state=session)
with bind_tool_context(session=session):
assert await reg.execute("trust_me", "{}") == "[approval] user (once)\nok"
assert await reg.execute("trust_me", "{}") == "[approval] grant (session)\nok"
assert prompts == ["trust_me"]
async def test_a_denied_call_carries_no_line() -> None:
reg = ToolRegistry([mutate], danger=_danger, on_confirm=lambda *_args: False)
out = await reg.execute("mutate", "{}")
assert out.startswith("error: ")
assert "[approval]" not in out
async def test_a_failed_call_carries_no_line() -> None:
"""An approved call that failed is an error first; the answer explains nothing."""
@tool(register=False)
async def boom() -> str:
return "error: boom"
reg = ToolRegistry([boom], danger=_danger, on_confirm=_allow)
assert await reg.execute("boom", "{}") == "error: boom"
async def test_a_handler_record_leads_the_result() -> None:
@tool(register=False)
async def policy_tool() -> str:
record_approval(Approval("user", "process"))
return "ran"
reg = ToolRegistry([policy_tool])
assert await reg.execute("policy_tool", "{}") == "[approval] user (process)\nran"
async def test_the_closest_answer_wins() -> None:
"""YOLO let the call run, but a human answered a policy prompt inside it."""
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
async def yolo_policy() -> str:
record_approval(Approval("user", "process"))
return "ran"
reg = ToolRegistry([yolo_policy], danger=_danger, on_confirm=_allow, yolo=True)
assert await reg.execute("yolo_policy", "{}") == "[approval] user (process)\nran"
async def test_the_line_survives_the_wire_cap() -> None:
approved = f"{format_approval(Approval('user', 'once'))}\n{'x' * 500}"
capped = cap_tool_message(ToolChatMessage(content=approved, tool_call_id="call-1"), 200)
assert capped.content.startswith("[approval] user (once)\n")
assert "document_id=call-1" in capped.content
def test_the_most_specific_record_wins() -> None:
records = [Approval("yolo", "auto"), Approval("grant", "session"), Approval("user", "once")]
assert most_specific_approval(records) == Approval("user", "once")
assert most_specific_approval([]) is None
+71
View File
@@ -0,0 +1,71 @@
"""A denial says which no it was: the human's, the clock's, or nobody's."""
from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.agent import Denial, DenialKind, ToolRegistry, tool
if TYPE_CHECKING:
from collections.abc import Mapping
def _danger(name: str, _args: Mapping[str, object]) -> str | None:
return f"mutate {name}"
@tool(register=False)
async def mutate() -> str:
return "ran"
def _registry(answer: object) -> ToolRegistry:
"""A registry whose one soft confirm answers *answer*."""
return ToolRegistry([mutate], danger=_danger, on_confirm=lambda *_args: answer)
async def test_a_declined_prompt_says_the_user_declined() -> None:
out = await _registry(Denial(DenialKind.DECLINED)).execute("mutate", "{}")
assert out.startswith("error: tool 'mutate' denied (")
assert "user declined" in out
assert "no answer" not in out
async def test_a_timeout_names_its_deadline_instead_of_the_user() -> None:
out = await _registry(Denial(DenialKind.TIMEOUT, seconds=30.0)).execute("mutate", "{}")
assert "no answer within 30s — denied by default" in out
assert "user declined" not in out
async def test_a_cancelled_prompt_says_so() -> None:
out = await _registry(Denial(DenialKind.CANCELLED)).execute("mutate", "{}")
assert "prompt cancelled" in out
async def test_no_prompt_says_nobody_was_asked() -> None:
out = await _registry(Denial(DenialKind.NO_PROMPT)).execute("mutate", "{}")
assert "no confirm hook or non-interactive" in out
async def test_a_bare_comment_still_reads_as_a_declined_human() -> None:
out = await _registry("my tax data").execute("mutate", "{}")
assert "user declined" in out
assert "user comment: my tax data" in out
def test_a_timeout_without_a_deadline_still_says_default_deny() -> None:
assert Denial(DenialKind.TIMEOUT).clause() == "no answer — denied by default"
def test_the_denial_clause_never_claims_a_human() -> None:
"""A timeout must not be reported as a decision, nor an absent prompt as one."""
assert "user" not in Denial(DenialKind.TIMEOUT, seconds=5.0).clause()
assert "user" not in Denial(DenialKind.NO_PROMPT).clause()
def test_from_answer_keeps_a_denial_and_reads_a_comment() -> None:
timeout = Denial(DenialKind.TIMEOUT, seconds=1.0)
assert Denial.from_answer(timeout) is timeout
assert Denial.from_answer("why not") == Denial(comment="why not")
assert Denial.from_answer(None) == Denial(DenialKind.DECLINED)
assert Denial.from_answer(False) == Denial(DenialKind.DECLINED)
+5 -5
View File
@@ -71,7 +71,7 @@ async def test_an_unknown_tool_is_error_text_without_images() -> None:
async def test_the_loop_keeps_tool_images_on_the_tool_message() -> None:
message, err = await _run_one_tool(_registry(), _call("shot"), max_result_chars=32_000)
message, err = await _run_one_tool(_registry(), _call("shot"), hard_max_chars=32_000)
assert err is None
assert isinstance(message, ToolChatMessage)
@@ -80,16 +80,16 @@ async def test_the_loop_keeps_tool_images_on_the_tool_message() -> None:
async def test_the_loop_leaves_the_images_field_unset_for_text_tools() -> None:
message, err = await _run_one_tool(_registry(), _call("plain"), max_result_chars=32_000)
message, err = await _run_one_tool(_registry(), _call("plain"), hard_max_chars=32_000)
assert err is None
assert message.images is UNSET
async def test_a_truncated_tool_result_keeps_its_images() -> None:
message, _ = await _run_one_tool(_registry(), _call("shot"), max_result_chars=1)
async def test_a_tool_result_above_the_storage_ceiling_keeps_its_images() -> None:
message, _ = await _run_one_tool(_registry(), _call("shot"), hard_max_chars=1)
assert "[Truncated" in message.content
assert "[Stored at the 1-char ceiling" in message.content
assert message.images == [_image()]
+33 -9
View File
@@ -26,6 +26,11 @@ def _danger(name: str, _args: Mapping[str, object]) -> str | None:
return f"mutate {name}"
def _approved(scope: str, text: str) -> str:
"""A result as the model reads it once a confirmation let the call run."""
return f"[approval] {scope}\n{text}"
async def test_loop_round_prompts_once_before_running_its_calls() -> None:
"""The loop aggregates a round's confirms and applies one answer to each call."""
batch_prompts: list[int] = []
@@ -54,7 +59,10 @@ async def test_loop_round_prompts_once_before_running_its_calls() -> None:
assert batch_prompts == [2]
assert single_prompts == []
assert [getattr(message, "content", None) for message in messages] == ["stamped a", "stamped b"]
assert [getattr(message, "content", None) for message in messages] == [
_approved("user (round)", "stamped a"),
_approved("user (round)", "stamped b"),
]
assert len(events) == len(tool_calls) * 2 # call event + result event per call
@@ -88,7 +96,11 @@ async def test_round_asks_once_for_many_mutating_calls() -> None:
results = [
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
]
assert results == ["stamped a", "stamped b", "stamped c"]
assert results == [
_approved("user (round)", "stamped a"),
_approved("user (round)", "stamped b"),
_approved("user (round)", "stamped c"),
]
assert ran == ["a", "b", "c"]
assert single_prompts == []
@@ -129,7 +141,11 @@ async def test_yolo_calls_are_left_out_of_the_prompt() -> None:
results = [
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
]
assert results == ["stamped a", "stamped b", "stamped c"]
assert results == [
_approved("yolo (auto)", "stamped a"),
_approved("user (round)", "stamped b"),
_approved("user (round)", "stamped c"),
]
assert single_prompts == []
@@ -153,7 +169,7 @@ async def test_single_request_keeps_the_one_call_prompt() -> None:
reg = ToolRegistry([stamp], danger=_danger, on_confirm=on_single, on_confirm_batch=on_batch)
assert await reg.prepare_batch_confirm([("stamp", '{"path": "a"}')]) == [None]
assert batch_prompts == []
assert await reg.execute("stamp", '{"path": "a"}') == "stamped a"
assert await reg.execute("stamp", '{"path": "a"}') == _approved("user (once)", "stamped a")
assert single_prompts == ["stamp"]
@@ -206,7 +222,8 @@ async def test_batch_deny_reaches_every_call_with_one_comment() -> None:
results = [
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
]
assert all(result.startswith("error: tool 'stamp' denied by user confirm") for result in results)
assert all(result.startswith("error: tool 'stamp' denied (") for result in results)
assert all("user declined" in result for result in results)
assert all("user comment: not these two" in result for result in results)
assert ran == []
@@ -254,7 +271,10 @@ async def test_manual_hands_the_round_back_to_the_per_call_prompt() -> None:
decisions = await reg.prepare_batch_confirm(calls)
assert decisions == [None, None]
assert [await reg.execute(name, arguments) for name, arguments in calls] == ["stamped a", "stamped b"]
assert [await reg.execute(name, arguments) for name, arguments in calls] == [
_approved("user (once)", "stamped a"),
_approved("user (once)", "stamped b"),
]
assert batch_prompts == [2]
assert single_prompts == ["stamp", "stamp"]
@@ -274,7 +294,10 @@ async def test_without_a_batch_hook_every_call_prompts_on_its_own() -> None:
reg = ToolRegistry([stamp], danger=_danger, on_confirm=on_single)
calls = [("stamp", '{"path": "a"}'), ("stamp", '{"path": "b"}')]
assert await reg.prepare_batch_confirm(calls) == [None, None]
assert [await reg.execute(name, arguments) for name, arguments in calls] == ["stamped a", "stamped b"]
assert [await reg.execute(name, arguments) for name, arguments in calls] == [
_approved("user (once)", "stamped a"),
_approved("user (once)", "stamped b"),
]
assert single_prompts == ["stamp", "stamp"]
@@ -306,8 +329,9 @@ async def test_batch_allow_records_the_trustable_grant() -> None:
calls = [("trust_me", '{"path": "a"}'), ("trust_me", '{"path": "b"}')]
decisions = await reg.prepare_batch_confirm(calls)
for path, decision in zip(("a", "b"), decisions, strict=True):
assert await reg.execute("trust_me", f'{{"path": "{path}"}}', decision=decision) == f"done {path}"
approve = _approved("user (round)", f"done {path}")
assert await reg.execute("trust_me", f'{{"path": "{path}"}}', decision=decision) == approve
assert has_grant(session, "trust_me")
# The TRUSTABLE grant, not the batch answer, keeps later calls unchecked.
assert await reg.execute("trust_me", '{"path": "c"}') == "done c"
assert await reg.execute("trust_me", '{"path": "c"}') == _approved("grant (session)", "done c")
assert single_prompts == []
+30 -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:
@@ -1003,6 +1018,17 @@ def test_every_builtin_tool_has_a_pretty_line() -> None:
assert missing == []
def test_a_confirmed_result_keeps_its_pretty_detail() -> None:
"""The approval line leads the model's text; a pretty detail reads past it."""
from plyngent.cli.display import _pretty_line_for, _pretty_parts
line = _pretty_line_for("write_file")
assert line is not None
approved = "[approval] user (once)\nwrote 120 characters to a.txt"
_prefix, detail = _pretty_parts(line, '{"path": "a.txt", "overwrite": true}', approved)
assert "(120 chars)" in detail
async def test_pretty_skill_tools(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(
_aiter(
+22 -5
View File
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
import pytest
import tomlkit
from plyngent.agent import Denial, DenialKind
from plyngent.cli.slash import handle_slash
from plyngent.cli.state import ReplState
from plyngent.config.models import DatabaseConfig, OpenAIProvider
@@ -16,6 +17,7 @@ from plyngent.memory import MemoryStore
from plyngent.prompting import NonInteractiveBackend, temporary_backend
from plyngent.tools import AccessMode, grant_session_access
from plyngent.tools.access import AccessDecision
from tests.test_prompting import ScriptedBackend
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -131,15 +133,30 @@ async def test_persist_and_load_roundtrip(state: ReplState, tmp_path: Path) -> N
assert state.session_state.access_grants == {outside: AccessMode.WRITE}
def test_yolo_hook_auto_approves_as_process_grant(state: ReplState, tmp_path: Path) -> None:
def test_yolo_does_not_answer_the_directory_access_prompt(
state: ReplState,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An out-of-workspace grant is ask-only, whatever YOLO mode says."""
from plyngent.cli import limits
state.set_yolo("on")
decision = state.directory_access_confirm_hook(tmp_path, AccessMode.EXEC, "", 30.0)
assert decision == AccessDecision(AccessMode.EXEC, persist=False)
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "y\n")
with temporary_backend(ScriptedBackend([])):
decision = state.directory_access_confirm_hook(tmp_path, AccessMode.EXEC, "", 30.0)
# Answered as a human prompt, so it is a session grant — not a process grant.
assert decision == AccessDecision(AccessMode.EXEC, persist=True)
def test_default_hook_denies_noninteractive(state: ReplState, tmp_path: Path) -> None:
def test_yolo_denies_the_directory_access_prompt_when_nobody_answers(
state: ReplState,
tmp_path: Path,
) -> None:
state.set_yolo("on")
with temporary_backend(NonInteractiveBackend()):
assert state.directory_access_confirm_hook(tmp_path, AccessMode.READ, "", 1.0) is None
answer = state.directory_access_confirm_hook(tmp_path, AccessMode.READ, "", 1.0)
assert answer == Denial(DenialKind.NO_PROMPT)
async def test_config_allow_paths_installed(
+148 -7
View File
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
import pytest
from plyngent.agent import Denial, DenialKind
from plyngent.cli.display import close_open_pretty_line
from plyngent.cli.limits import (
auto_continue_enabled,
@@ -19,9 +20,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
@@ -128,7 +131,8 @@ def test_pty_offer_raise_uses_auto_continue(clean_auto_continue: None) -> None:
def test_prompt_confirm_tool_default_deny() -> None:
backend = ScriptedBackend([], confirms=[False])
with temporary_backend(backend):
assert prompt_confirm_tool("delete_path", {"path": "x"}, "delete path 'x'") is False
answer = prompt_confirm_tool("delete_path", {"path": "x"}, "delete path 'x'")
assert answer == Denial(DenialKind.DECLINED)
def test_format_tool_confirm_box_multiline() -> None:
@@ -157,7 +161,7 @@ def test_policy_confirm_noninteractive_denies() -> None:
from plyngent.prompting import NonInteractiveBackend
with temporary_backend(NonInteractiveBackend()):
assert prompt_policy_command_confirm("sudo", ["sudo", "id"], 1.0) is False
assert prompt_policy_command_confirm("sudo", ["sudo", "id"], 1.0) == Denial(DenialKind.NO_PROMPT)
def test_directory_access_prompt_noninteractive_denies() -> None:
@@ -168,7 +172,9 @@ def test_directory_access_prompt_noninteractive_denies() -> None:
from plyngent.tools.workspace import AccessMode
with temporary_backend(NonInteractiveBackend()):
assert prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 1.0) is None
assert prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 1.0) == Denial(
DenialKind.NO_PROMPT
)
def test_directory_access_prompt_yes_and_level(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -189,9 +195,14 @@ def test_directory_access_prompt_yes_and_level(monkeypatch: pytest.MonkeyPatch)
AccessMode.EXEC
)
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "n\n")
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) is None
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == Denial(
DenialKind.DECLINED
)
# Silence is its own answer: the deadline is named, not the human.
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: None)
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) is None
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == Denial(
DenialKind.TIMEOUT, seconds=30.0
)
class _AnswerSource:
@@ -241,7 +252,9 @@ def test_directory_access_denied_by_the_notification() -> None:
from plyngent.tools.workspace import AccessMode
with temporary_backend(ScriptedBackend([])), _answered("deny"):
assert limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0) is None
assert limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0) == Denial(
DenialKind.DECLINED
)
def test_tool_confirm_takes_a_notification_answer() -> None:
@@ -251,7 +264,7 @@ def test_tool_confirm_takes_a_notification_answer() -> None:
def test_tool_confirm_denied_by_the_notification() -> None:
with temporary_backend(ScriptedBackend([""])), _answered("deny"):
assert prompt_confirm_tool("run_argv", {}, "runs a command") is False
assert prompt_confirm_tool("run_argv", {}, "runs a command") == Denial(DenialKind.DECLINED)
@pytest.mark.skipif(sys.platform == "win32", reason="a pty is POSIX-only")
@@ -332,6 +345,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
+3 -3
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from plyngent.agent import BatchConfirmDecision, ToolConfirmRequest
from plyngent.agent import BatchConfirmDecision, Denial, DenialKind, ToolConfirmRequest
from plyngent.cli.limits import (
_prompt_confirm_batch_sync,
format_batch_confirm_box,
@@ -69,13 +69,13 @@ def test_batch_prompt_denies_with_a_comment() -> None:
def test_batch_prompt_defaults_to_deny() -> None:
with temporary_backend(ScriptedBackend([])):
decision = _prompt_confirm_batch_sync(_requests())
assert decision == BatchConfirmDecision(allowed=False)
assert decision == BatchConfirmDecision(allowed=False, denial=Denial(DenialKind.DECLINED))
def test_batch_prompt_denies_when_not_interactive() -> None:
with temporary_backend(NonInteractiveBackend()):
decision = _prompt_confirm_batch_sync(_requests())
assert decision == BatchConfirmDecision(allowed=False)
assert decision == BatchConfirmDecision(allowed=False, denial=Denial(DenialKind.NO_PROMPT))
async def test_batch_prompt_async_allows() -> None:
+297 -24
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,14 +259,14 @@ 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
@@ -285,23 +298,147 @@ def test_no_answer_is_expected_without_the_capability() -> None:
assert handle.supports_actions is False
def test_an_ask_offers_a_reply_field_instead_of_buttons() -> None:
def test_a_question_offers_a_reply_field_without_the_answers() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("ask", "Which branch?")) as handle:
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
@@ -346,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)
@@ -379,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
# --------------------------------------------------------------------------- #
@@ -398,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:
+1
View File
@@ -23,6 +23,7 @@ def test_agent_section_defaults(tmp_path: Path) -> None:
assert "professional coding agent" in store.agent_config.system_prompt
assert "### Workspace" in store.agent_config.tool_directives
assert store.agent_config.max_tool_result_chars == 32_000
assert store.agent_config.hard_max_tool_result_chars == 2_000_000
assert store.agent_config.parallel_tools is True
assert store.agent_config.confirm_destructive is True
assert store.agent_config.path_denylist == []
+45 -1
View File
@@ -356,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] = []
@@ -374,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] = []
+18 -1
View File
@@ -123,7 +123,24 @@ async def test_deny_none(
outside = tmp_path_factory.mktemp("timeout").resolve()
set_directory_access_confirm_hook(lambda *_a: None)
out = await call_async(request_directory_access, str(outside))
assert "denied (declined or timed out" in out
assert "denied (user declined)" in out
assert session.access_grants == {}
async def test_a_prompt_timeout_is_not_reported_as_a_decision(
workspace: object,
session: SessionState,
tmp_path_factory: pytest.TempPathFactory,
) -> None:
"""The deadline the prompt waited out is what the model reads."""
from plyngent.agent import Denial, DenialKind
del workspace
outside = tmp_path_factory.mktemp("silence").resolve()
set_directory_access_confirm_hook(lambda *_a: Denial(DenialKind.TIMEOUT, seconds=12.0))
out = await call_async(request_directory_access, str(outside))
assert "denied (no answer within 12s — denied by default)" in out
assert "user" not in out
assert session.access_grants == {}
+4
View File
@@ -61,6 +61,10 @@ def test_read_only_classification() -> None:
"read_file",
"view_image",
"get_truncated",
"read_document",
"search_document",
"document_info",
"list_documents",
"glob_paths",
"regex_files",
"listdir",
+12 -10
View File
@@ -33,9 +33,9 @@ async def test_yolo_only_skips_yolo_tagged_tools() -> None:
on_confirm=confirm,
yolo=True,
)
assert await reg.execute("yolo_ok", "{}") == "y"
assert await reg.execute("yolo_ok", "{}") == "[approval] yolo (auto)\ny"
assert calls == []
assert await reg.execute("no_yolo", "{}") == "n"
assert await reg.execute("no_yolo", "{}") == "[approval] user (once)\nn"
assert calls == ["no_yolo"]
@@ -55,7 +55,7 @@ async def test_yolo_off_keeps_the_yolo_tagged_prompt() -> None:
return True
reg = ToolRegistry([yolo_ok], danger=danger, on_confirm=confirm, yolo=False)
assert await reg.execute("yolo_ok", "{}") == "y"
assert await reg.execute("yolo_ok", "{}") == "[approval] user (once)\ny"
assert calls == ["yolo_ok"]
@@ -75,7 +75,7 @@ async def test_yolo_keeps_a_reason_that_refuses_the_skip() -> None:
return True
reg = ToolRegistry([escalated], danger=danger, on_confirm=confirm, yolo=True)
assert await reg.execute("escalated", "{}") == "x"
assert await reg.execute("escalated", "{}") == "[approval] user (once)\nx"
assert seen == ["interpreter 'python' + privilege escalation sudo"]
@@ -103,8 +103,10 @@ async def test_trustable_grant_once() -> None:
session_state=session,
)
with bind_tool_context(session=session):
assert await reg.execute("trust_me", "{}") == "ok"
assert await reg.execute("trust_me", "{}") == "ok"
assert await reg.execute("trust_me", "{}") == "[approval] user (once)\nok"
# The TRUSTABLE grant answers from here on: nobody is asked, and the
# result says so.
assert await reg.execute("trust_me", "{}") == "[approval] grant (session)\nok"
assert calls == ["trust_me"]
@@ -123,8 +125,8 @@ async def test_untagged_trustable_prompts_every_time() -> None:
return True
reg = ToolRegistry([always_ask], danger=danger, on_confirm=confirm, yolo=False)
assert await reg.execute("always_ask", "{}") == "ok"
assert await reg.execute("always_ask", "{}") == "ok"
assert await reg.execute("always_ask", "{}") == "[approval] user (once)\nok"
assert await reg.execute("always_ask", "{}") == "[approval] user (once)\nok"
assert calls == ["always_ask", "always_ask"]
@@ -151,7 +153,7 @@ async def test_trustable_grant_persists_to_session_data() -> None:
yolo=False,
session_state=session,
)
assert await reg.execute("trust_me", "{}") == "ok"
assert await reg.execute("trust_me", "{}") == "[approval] user (once)\nok"
assert has_grant(session, "trust_me")
loaded = await store.load()
assert isinstance(loaded, dict)
@@ -170,7 +172,7 @@ async def test_trustable_grant_persists_to_session_data() -> None:
yolo=False,
session_state=session2,
)
assert await reg2.execute("trust_me", "{}") == "ok"
assert await reg2.execute("trust_me", "{}") == "[approval] grant (session)\nok"
assert calls == ["trust_me"]
+158
View File
@@ -0,0 +1,158 @@
"""Document tools: read/search/list the tool results the request-prep pass capped."""
from __future__ import annotations
from contextlib import contextmanager
from typing import TYPE_CHECKING
import msgspec
from plyngent.lmproto.openai_compatible.model import (
AnyChatMessage,
AssistantChatMessage,
AssistantFunctionTool,
AssistantFunctionToolCall,
ToolChatMessage,
UserChatMessage,
)
from plyngent.tools.context import InstanceState, bind_instance
from plyngent.tools.documents import DocumentStore
from plyngent.tools.file import document_info, list_documents, read_document, search_document
from tests.test_tools.helpers import call_async
if TYPE_CHECKING:
from collections.abc import Generator, Sequence
def _history(text: str, *, tool_call_id: str = "call-1", tool_name: str = "some_tool") -> list[AnyChatMessage]:
return [
UserChatMessage(content="go"),
AssistantChatMessage(
content="",
tool_calls=[
AssistantFunctionToolCall(
id=tool_call_id,
function=AssistantFunctionTool(name=tool_name, arguments="{}"),
)
],
),
ToolChatMessage(content=text, tool_call_id=tool_call_id),
]
@contextmanager
def bound_document(
text: str,
*,
tool_call_id: str = "call-1",
tool_name: str = "some_tool",
) -> Generator[DocumentStore]:
"""Bind an instance whose document store reads one tool result."""
messages = _history(text, tool_call_id=tool_call_id, tool_name=tool_name)
instance = InstanceState()
store = DocumentStore(lambda: messages)
instance.extras["documents"] = store
with bind_instance(instance):
yield store
_TEN_LINES = "".join(f"line {i}\n" for i in range(1, 11))
async def test_read_document_without_store(workspace: object) -> None:
del workspace
out = await call_async(read_document, "call-1")
assert out.startswith("error:")
async def test_read_document_unknown_id() -> None:
with bound_document(_TEN_LINES):
out = await call_async(read_document, "nope")
assert out.startswith("error:")
assert "unknown document" in out
async def test_read_document_window_and_range() -> None:
with bound_document(_TEN_LINES):
window = await call_async(read_document, "call-1", offset=0, limit=3)
whole = await call_async(read_document, "call-1")
mid = await call_async(read_document, "call-1", offset=2, limit=2)
assert window == "L1-3\nline 1\nline 2\nline 3\n[... more: next offset=3]"
assert whole == f"L1-10\n{_TEN_LINES}"
assert "next offset" not in whole
assert mid.startswith("L3-4\nline 3\nline 4\n")
async def test_read_document_numbered() -> None:
with bound_document(_TEN_LINES):
out = await call_async(read_document, "call-1", offset=1, limit=2, with_lineno=True)
assert out.startswith(" 2|line 2\n 3|line 3\n")
async def test_read_document_cut_names_next_offset() -> None:
text = "".join(f"a very long line number {i}\n" for i in range(1, 21))
with bound_document(text):
out = await call_async(read_document, "call-1", max_chars=40)
assert "next offset=" in out
offset = int(out.rsplit("next offset=", 1)[1].split("]", 1)[0])
nxt = await call_async(read_document, "call-1", offset=offset)
assert nxt.startswith(f"L{offset + 1}-")
async def test_read_document_offset_past_end() -> None:
with bound_document(_TEN_LINES):
out = await call_async(read_document, "call-1", offset=99)
assert out == ""
async def test_search_document_hits_and_miss() -> None:
with bound_document(_TEN_LINES):
hits = await call_async(search_document, "call-1", r"line \d+")
miss = await call_async(search_document, "call-1", "absent")
assert hits.splitlines()[0] == "L1: line 1"
assert "L10: line 10" in hits
assert miss == "(no matches)"
async def test_search_document_context_and_cap() -> None:
with bound_document(_TEN_LINES):
block = await call_async(search_document, "call-1", "line 5", context_lines=1)
capped = await call_async(search_document, "call-1", r"line \d+", max_matches=2)
assert block == "L4: line 4\nL5: line 5\nL6: line 6"
assert "...[truncated at 2 matches]" in capped
async def test_search_document_invalid_regex() -> None:
with bound_document(_TEN_LINES):
out = await call_async(search_document, "call-1", "(")
assert out.startswith("error: invalid regex")
async def test_document_info_and_list() -> None:
with bound_document(_TEN_LINES, tool_name="run_argv"):
info = await call_async(document_info, "call-1")
listing = await call_async(list_documents)
assert "tool=run_argv" in info
assert "lines=10" in info
assert "first=line 1" in info
assert "last=line 10" in info
assert "document_id=call-1 tool=run_argv" in listing
assert "chars=" in listing
async def test_list_documents_without_store(workspace: object) -> None:
del workspace
assert (await call_async(list_documents)).startswith("error:")
async def test_document_survives_serialization_roundtrip() -> None:
"""A resume reloads messages from JSON; the store still resolves the id."""
messages: Sequence[AnyChatMessage] = _history(_TEN_LINES)
reloaded: list[AnyChatMessage] = [
msgspec.convert(msgspec.to_builtins(message), type=AnyChatMessage) for message in messages
]
instance = InstanceState()
instance.extras["documents"] = DocumentStore(lambda: reloaded)
with bind_instance(instance):
out = await call_async(read_document, "call-1", offset=0, limit=1)
assert out.startswith("L1-1\nline 1\n")
+58 -22
View File
@@ -23,7 +23,6 @@ from plyngent.tools.net.policy import (
parse_fetch_url,
soft_confirm_reason,
)
from plyngent.tools.truncate_token import decode_truncate_token
from tests.test_tools.helpers import call_async
if TYPE_CHECKING:
@@ -189,6 +188,50 @@ def test_classify_danger_fetch() -> None:
assert isinstance(reason2, str) and "DELETE" in reason2
async def test_a_private_host_answer_is_recorded_as_a_user_process_approval(workspace: Path) -> None:
"""The prompt that opened loopback for this process is what the result says."""
del workspace
from plyngent.agent import Approval
from plyngent.tools.context import collect_approvals
from plyngent.tools.net.grants import ensure_host_allowed, set_fetch_policy_confirm_hook
set_fetch_policy_confirm_hook(lambda *_args: True)
try:
with collect_approvals() as recorded:
await ensure_host_allowed("http://127.0.0.1:9/")
finally:
set_fetch_policy_confirm_hook(None)
assert recorded == [Approval("user", "process")]
async def test_a_private_host_timeout_names_the_deadline(workspace: Path) -> None:
"""A prompt that nobody answered says the clock, not a human's no."""
del workspace
from plyngent.agent import Denial, DenialKind
from plyngent.tools.net.grants import ensure_host_allowed, set_fetch_policy_confirm_hook
from plyngent.tools.net.policy import FetchPolicyError
set_fetch_policy_confirm_hook(lambda *_args: Denial(DenialKind.TIMEOUT, seconds=7.0))
try:
with pytest.raises(FetchPolicyError, match="no answer within 7s"):
await ensure_host_allowed("http://127.0.0.1:9/")
finally:
set_fetch_policy_confirm_hook(None)
async def test_a_private_host_grant_is_recorded_as_a_process_grant(workspace: Path) -> None:
"""A second fetch to the granted host asks nobody, and says so."""
del workspace
from plyngent.agent import Approval
from plyngent.tools.context import collect_approvals
from plyngent.tools.net.grants import ensure_host_allowed
grant_private_host("127.0.0.1", 9)
with collect_approvals() as recorded:
await ensure_host_allowed("http://127.0.0.1:9/")
assert recorded == [Approval("grant", "process")]
async def test_fetch_get_post_put_delete(workspace: Path, http_server: str) -> None:
del workspace
base = http_server
@@ -299,37 +342,30 @@ async def test_fetch_truncation_and_binary(workspace: Path, http_server: str) ->
assert "binary body omitted" in out_bin
async def test_fetch_char_truncation_embeds_token(workspace: Path, http_server: str) -> None:
del workspace
port = int(http_server.rsplit(":", 1)[1])
grant_private_host("127.0.0.1", port)
out = await call_async(fetch_local, f"{http_server}/big", max_chars=1000)
assert "truncated=true" in out
body = out.split("--- body ---", 1)[-1]
assert "truncate_token=" in body
token_line = next(ln for ln in body.splitlines() if "truncate_token=" in ln)
token_str = token_line.split("truncate_token=", 1)[1].rstrip("]")
token = decode_truncate_token(token_str)
assert token is not None
# The body is kept in memory, so resuming never repeats the request.
assert token.kind == "memory"
assert token.limit == 1000
assert 0 < token.offset < 1000
async def test_fetch_offset_skips_body(workspace: Path, http_server: str) -> None:
del workspace
port = int(http_server.rsplit(":", 1)[1])
grant_private_host("127.0.0.1", port)
# /big body is 5000 chars; offset 4900 leaves 100 chars, no token needed.
out = await call_async(fetch_local, f"{http_server}/big", max_chars=200, offset=4900)
# /big body is 5000 chars; offset 4900 leaves the last 100 chars.
out = await call_async(fetch_local, f"{http_server}/big", offset=4900)
body = out.split("--- body ---", 1)[-1]
assert body.strip() == "x" * 100
assert "truncate_token=" not in out
async def test_fetch_keeps_the_body_whole(workspace: Path, http_server: str) -> None:
"""The body is stored whole (the request-prep pass caps it on the wire)."""
del workspace
port = int(http_server.rsplit(":", 1)[1])
grant_private_host("127.0.0.1", port)
out = await call_async(fetch_local, f"{http_server}/big")
body = out.split("--- body ---", 1)[-1].strip()
assert body == "x" * 5000
assert "truncate_token=" not in out
async def test_fetch_private_denied_without_grant(workspace: Path, http_server: str) -> None:
del workspace
# No grant, no policy hook → hard deny (even though server is up).
+7 -9
View File
@@ -24,8 +24,8 @@ class _Client:
return self.page
def _tool(client: _Client, *, max_chars: int = 32_000):
return build_fetch_tool(client, default_max_chars=max_chars)
def _tool(client: _Client):
return build_fetch_tool(client)
def test_definition_shape() -> None:
@@ -33,7 +33,7 @@ def test_definition_shape() -> None:
assert definition.name == FETCH_TOOL_NAME == "fetch"
assert definition.tags == ToolTag.LOCAL | ToolTag.READ_ONLY
properties = definition.parameters["properties"]
assert set(properties) == {"url", "offset", "max_chars"}
assert set(properties) == {"url", "offset"}
assert definition.parameters["required"] == ["url"]
assert "fetch_local" in definition.description
@@ -48,7 +48,6 @@ async def test_handler_renders_title_url_and_content() -> None:
"title=Example article",
"url=https://example.com/article",
"chars=16",
"truncated=false",
"--- content ---",
"# Heading",
"",
@@ -57,14 +56,13 @@ async def test_handler_renders_title_url_and_content() -> None:
@pytest.mark.asyncio
async def test_offset_and_truncation_keep_the_remainder_resumable() -> None:
async def test_offset_skips_leading_content() -> None:
client = _Client(page=FetchResponse(title="", content="0123456789"))
result = await _tool(client, max_chars=3).handler(url="https://example.com/", offset=5)
result = await _tool(client).handler(url="https://example.com/", offset=5)
assert "title=(untitled)" in result
assert "--- content ---\n567" in result
assert "truncated=true" in result
assert "truncate_token=" in result
assert "--- content ---\n56789" in result
assert "truncate_token=" not in result
@pytest.mark.asyncio
+3 -39
View File
@@ -84,52 +84,16 @@ async def test_get_truncated_file_missing(workspace: Path) -> None:
assert out.startswith("error:")
async def test_get_truncated_serves_a_fetch_body_without_refetching(monkeypatch) -> None:
"""A fetch body's token reads the stored remainder; the request is not repeated."""
from plyngent.tools.net.fetch_local import format_fetch_result
fetch_module = importlib.import_module("plyngent.tools.net.fetch_local")
async def _must_not_refetch(*_args: object, **_kwargs: object) -> str:
msg = "resuming a fetch body must not re-run the request"
raise AssertionError(msg)
monkeypatch.setattr(fetch_module, "http_fetch", _must_not_refetch)
first = format_fetch_result(
status=200,
final_url="https://example.com/x",
content_type="text/plain",
body_text="x" * 600,
body_bytes=600,
truncated=False,
redirects=0,
method="GET",
security="public",
warnings=[],
body_kind="text",
max_chars=200,
)
token = _extract_token(first)
assert token is not None
parsed = decode_truncate_token(token)
assert parsed is not None
assert parsed.kind == "memory"
out = await call_async(get_truncated, token, max_chars=200)
content, _, _ = out.partition("\n[Truncated")
assert content == "x" * len(content)
assert 0 < len(content) <= 200
async def test_get_truncated_memory_chains() -> None:
from plyngent.tools.truncate_token import truncate_generic
from plyngent.tools.truncate_token import truncate_to_memory
bounded = truncate_generic("m" * 1000, 200)
bounded, _token = truncate_to_memory("m" * 1000, 200)
assert "truncate_token=" in bounded
token = bounded.split("truncate_token=", 1)[1].rstrip("]")
parsed = decode_truncate_token(token)
assert parsed is not None
assert parsed.kind == "memory"
cursor = parsed.offset # chars already delivered by truncate_generic
cursor = parsed.offset # chars already delivered by truncate_to_memory
delivered = cursor
for _ in range(20):
out = await call_async(get_truncated, token, max_chars=200)
+90 -12
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,
@@ -190,14 +192,14 @@ async def test_run_argv_timeout_keeps_partial_output(workspace: object) -> None:
assert "partial-out" in out
async def test_run_argv_stdout_cap_embeds_token(workspace: object) -> None:
async def test_run_argv_returns_the_full_stdout(workspace: object) -> None:
del workspace
# Per-stream cap default is 32000 chars: a bigger stdout embeds a token.
# The result is stored whole; the request-prep pass caps it on the wire and
# names its document, so nothing is cut at the tool.
out = await call_async(run_argv, _py("print('x' * 40000)"))
assert "truncate_token=" in out
token_line = next(ln for ln in out.splitlines() if "truncate_token=" in ln)
assert "[Truncated (32000 chars max.;" in token_line
assert "[Truncated" in out
body = out.split("--- stdout ---", 1)[1].split("--- stderr ---", 1)[0].strip()
assert body == "x" * 40000
assert "truncate_token=" not in out
async def test_run_argv_small_stdout_no_marker(workspace: object) -> None:
@@ -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 ""
+41 -1
View File
@@ -309,10 +309,26 @@ async def test_skill_write_prompt_denial_is_reported(tmp_path: Path) -> None:
with _bound(tmp_path, root, policy=SkillWritePolicy(confirm=_deny, timeout=3.0)):
out = await skill_edit.handler("acme", old_string="Before.", new_string="After.")
assert out.startswith("error: skill write to ")
assert out.endswith("denied (declined or timed out after 3s)")
assert out.endswith("denied (user declined)")
assert "After." not in (root / "acme" / SKILL_FILE).read_text(encoding="utf-8")
async def test_a_skill_write_timeout_names_the_deadline(tmp_path: Path) -> None:
"""Nobody answering is not the same message as a human saying no."""
from plyngent.agent import Denial, DenialKind
root = _write_root(tmp_path)
_ = _write_skill(root, "acme", body="Before.")
def silent(*_args: object) -> SkillWriteDecision:
return SkillWriteDecision(allow=False, denial=Denial(DenialKind.TIMEOUT, seconds=3.0))
with _bound(tmp_path, root, policy=SkillWritePolicy(confirm=silent, timeout=3.0)):
out = await skill_edit.handler("acme", old_string="Before.", new_string="After.")
assert out.endswith("denied (no answer within 3s — denied by default)")
assert "user" not in out
async def test_skill_write_confirm_sees_the_action_and_the_skill(tmp_path: Path) -> None:
root = _write_root(tmp_path)
seen: list[tuple[Path, str, str, float]] = []
@@ -344,6 +360,30 @@ async def test_skill_write_session_grant_is_asked_once(tmp_path: Path) -> None:
assert (root / "acme" / "notes.md").read_text(encoding="utf-8") == "note\n"
async def test_a_skill_write_says_which_answer_let_it_through(tmp_path: Path) -> None:
"""Once, this chat, and the standing config each read differently."""
from plyngent.agent import Approval
from plyngent.tools.context import collect_approvals
root = _write_root(tmp_path)
with _bound(tmp_path, root, policy=SkillWritePolicy(confirm=lambda *_args: True)), collect_approvals() as once:
created = await skill_create.handler("once-skill", "d", "b")
def for_the_chat(*_args: object) -> SkillWriteDecision:
return SkillWriteDecision(allow=True, session=True)
with _bound(tmp_path, root, policy=SkillWritePolicy(confirm=for_the_chat)), collect_approvals() as chat:
_ = await skill_create.handler("chat-skill", "d", "b")
_ = await skill_edit.handler("chat-skill", append="\nMore.\n")
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)), collect_approvals() as config:
_ = await skill_create.handler("config-skill", "d", "b")
assert once == [Approval("user", "once")], created
assert chat == [Approval("user", "session"), Approval("grant", "session")]
assert config == [Approval("config", "policy")]
async def test_skill_write_denylist_wins_over_a_grant(tmp_path: Path) -> None:
root = _write_root(tmp_path)
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
+12 -12
View File
@@ -5,7 +5,7 @@ from plyngent.tools.truncate_token import (
decode_truncate_token,
encode_truncate_token,
get_remainder,
truncate_generic,
truncate_to_memory,
truncate_with_token,
truncation_marker,
)
@@ -125,26 +125,26 @@ def test_truncate_with_token_nonzero_start() -> None:
assert len(out) <= 200
def test_truncate_generic_short_untouched() -> None:
assert truncate_generic("tiny", 200) == "tiny"
def test_truncate_to_memory_short_untouched() -> None:
out, token = truncate_to_memory("tiny", 200)
assert out == "tiny"
assert token is None
def test_truncate_generic_embeds_memory_token() -> None:
def test_truncate_to_memory_embeds_memory_token() -> None:
text = "a" * 500
out = truncate_generic(text, 200)
out, token = truncate_to_memory(text, 200)
assert token is not None
assert len(out) <= 200
assert "[Truncated (200 chars max." in out
token_str = out.split("truncate_token=", 1)[1].rstrip("]")
token = decode_truncate_token(token_str)
assert token is not None
assert token.kind == "memory"
# Remainder is the full original text; token offset points into it.
remainder = get_remainder(token.location)
assert remainder == text
assert get_remainder(token.location) == text
assert token.offset < len(text)
def test_truncate_generic_no_marker_when_fits() -> None:
out = truncate_generic("a" * 50, 200)
def test_truncate_to_memory_no_marker_when_fits() -> None:
out, token = truncate_to_memory("a" * 50, 200)
assert out == "a" * 50
assert "Truncated" not in out
assert token is None
+68 -1
View File
@@ -50,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
@@ -220,6 +235,41 @@ def test_command_denylist_policy_confirm_allow(workspace: object) -> None:
clear_policy_allowed_commands()
def test_a_denylist_answer_is_recorded_as_a_user_process_approval(workspace: object) -> None:
"""The answer that let the call through is what the result has to say."""
del workspace
from plyngent.agent import Approval
from plyngent.tools.context import collect_approvals
from plyngent.tools.workspace import clear_policy_allowed_commands, set_policy_confirm_hook
set_policy_confirm_hook(lambda *_args: True)
clear_policy_allowed_commands()
try:
with collect_approvals() as recorded:
check_command_allowed(["sudo", "id"])
finally:
set_policy_confirm_hook(None)
clear_policy_allowed_commands()
assert recorded == [Approval("user", "process")]
def test_a_matching_denylist_grant_is_recorded_as_a_process_grant(workspace: object) -> None:
"""Nobody is asked the second time, and the result says so."""
del workspace
from plyngent.agent import Approval
from plyngent.tools.context import collect_approvals
from plyngent.tools.workspace import clear_policy_allowed_commands, grant_policy_command
clear_policy_allowed_commands()
grant_policy_command("sudo")
try:
with collect_approvals() as recorded:
check_command_allowed(["sudo", "id"])
finally:
clear_policy_allowed_commands()
assert recorded == [Approval("grant", "process")]
def test_command_denylist_policy_confirm_deny(workspace: object) -> None:
del workspace
from plyngent.tools.workspace import (
@@ -230,13 +280,30 @@ def test_command_denylist_policy_confirm_deny(workspace: object) -> None:
set_policy_confirm_hook(lambda *_a: False)
clear_policy_allowed_commands()
try:
with pytest.raises(WorkspaceError, match="declined or timed out"):
with pytest.raises(WorkspaceError, match="user declined"):
check_command_allowed(["sudo", "echo", "x"])
finally:
set_policy_confirm_hook(None)
clear_policy_allowed_commands()
def test_command_denylist_policy_timeout_names_the_deadline(workspace: object) -> None:
"""Silence is reported as the clock, never as the human's decision."""
del workspace
from plyngent.agent import Denial, DenialKind
from plyngent.tools.workspace import clear_policy_allowed_commands, set_policy_confirm_hook
set_policy_confirm_hook(lambda *_a: Denial(DenialKind.TIMEOUT, seconds=3.0))
clear_policy_allowed_commands()
try:
with pytest.raises(WorkspaceError, match="no answer within 3s") as refusal:
check_command_allowed(["sudo", "echo", "x"])
finally:
set_policy_confirm_hook(None)
clear_policy_allowed_commands()
assert "declined" not in str(refusal.value)
def test_command_denylist_policy_confirm_timeout_value(workspace: object) -> None:
del workspace
from plyngent.tools.workspace import (
+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,