24 Commits

Author SHA1 Message Date
NCBM 2769fb62de deps: lock the notify extra
CI installs with `pdm install --check -G :all`, and `-G :all` covers extras
too, so a `[notify]` extra that was never locked made the install step fail
outright: "Requested groups not in lockfile: notify". `pdm lock -G :all` brings
`desktop-notifier` and its per-platform dependencies (dbus-fast, rubicon-objc,
the winrt projections) into the lock, and re-resolving picked up the drift that
had accumulated since the last one — platformdirs 4.13.5, sqlalchemy 2.1.5,
urllib3-future 2.25.903.
2026-10-21 20:30:56 +08:00
NCBM cb4e9b3744 release: plyngent 0.11.0 2026-10-21 20:29:04 +08:00
NCBM 4b90718191 core/config: re-read [skills] on reload
`ConfigStore.reload()` re-parsed every section except `[skills]`, so
`/model --persist` and any other in-place reload kept serving the skill config
the process started with — including `[skills].enabled`, which is what decides
whether the skill tools exist at all.
2026-10-21 20:26:08 +08:00
NCBM 0e8b0f8bd4 docs: describe deletes that go to the trash
A new page, `doc/trash.md`, covers what a delete does now: where a path lands on
each platform, what is still deleted outright (an explicit `permanent=true`, a
host that cleared `[trash]`, scratch paths under the OS temp dir, the
`copy_path` / `move_path` overwrite, the temporary-workspace cleanup), what
happens when a trashing is refused — the file stays and the error says how to
delete it on purpose — and the two things the trash cannot help with: a shell
`rm` and anything a plugin or MCP server does on its own.

`[trash]` joins the example config and the README snippet, the README gains the
one-paragraph version, `AGENTS.md` the tool/config notes (including the confirm
wording a delete now uses), and the default tool playbook tells the model that
`delete_path` is undoable and what `permanent=true` costs.
2026-10-21 20:25:27 +08:00
NCBM 2369592473 core/tools+cli/display: name the trash in the delete confirm
The box asked "delete path 'x'", which reads as final even now that it is not.
It now says "move path 'x' to the system trash" — and "delete path 'x'
permanently" when the path really is destroyed: an explicit `permanent=true`, a
host that turned `[trash] enabled` off, or a scratch path under the OS temporary
directory. The classifier resolves the path to answer that, and falls back to
the trash wording when path policy will refuse the call anyway.

The pretty line follows: a delete that landed in the trash prints `(to trash)`
instead of `(done)`, so a session's transcript still shows which deletes could
be undone.
2026-10-21 20:23:54 +08:00
NCBM 17813281d8 core/cli: hand the trash switch to the tools
`[trash] enabled` now reaches `delete_path` through the bound instance's
workspace policy, the same channel `[agent] path_denylist` already travels: set
when the session state is built and re-read by `reload_config_from_disk`, so a
config reload in the middle of a chat changes the next delete too.

The policy flag is what `delete_path` consults, so a host that never sets it
gets the safe default (deletes go to the trash) instead of a silent no-op.
2026-10-21 20:22:50 +08:00
NCBM c4a287c464 core/config: add the [trash] section
`[trash]` owns the one policy `delete_path` needs from the human: whether a
delete is a move to the desktop's trash (`enabled = true`, the default) or a
removal that cannot be undone (`false`, which is what the tool did before).

Unknown keys degrade to the defaults, the way `[mcp]` does, so a typo cannot
silently switch a destructive policy on. `reload()` re-reads the section.
2026-10-21 20:19:54 +08:00
NCBM 7b7c90b8ee core/tools: send a deleted path to the system trash
`delete_path` is the only destructive file entry point the agent has, and it
carries `ToolTag.YOLO` — under `--yes` / `/yolo` it runs with no confirmation at
all. A delete is now a move into the desktop's own trash (freedesktop trash on
Linux, Finder trash on macOS, Recycle Bin on Windows) through `send2trash`, so
the human can put the file back with the file manager they already have. The new
`permanent=true` argument is the old behaviour, chosen on purpose.

A trashing that fails is reported, never downgraded to a deletion: the human was
told the path is recoverable, so a volume with no writable trash directory
leaves the file where it is and the error names `permanent=true` as the way to
delete it anyway. A broken install (the library is a required dependency) is
reported the same way.

Scratch paths under the OS temporary directory are still deleted for good —
nobody restores them, and a trash on the `/tmp` volume would only collect them.
The rule now lives in `tools/trash.py` (`under_system_temp`) and the temporary
workspace cleanup uses it too, instead of keeping a second copy of the check.
2026-10-21 20:18:35 +08:00
NCBM 290f3daf60 deps: add send2trash
Deletes are about to become recoverable, and send2trash is what every desktop
already understands: the freedesktop trash specification on Linux (its own
implementation unless PyGObject/GIO is there), the Finder trash on macOS and the
Recycle Bin on Windows. It is pure Python over ctypes — no compilation, no
runtime dependencies — so it can sit on the default install instead of behind an
extra.

A required dependency rather than an optional one on purpose: a missing extra
would silently turn "recoverable" into "gone", and a safety net that disappears
without a word is worse than no safety net.
2026-10-21 20:15:41 +08:00
NCBM f3bd511251 test: cover the notify install gate
`_setup_hooks` installs the wait hook only for an interactive run: a one-shot
`plyngent "…"` never waits on a human, so `[notify]` must not arm a notifier
that could never fire. The test asserts the hook is cleared again when the run
is not interactive.
2026-10-21 20:04:17 +08:00
NCBM b197c98ce9 docs: describe prompt notifications
A prompt that waits on the human is announced after a grace period, and the
notification can carry the answer: allow / deny / review buttons for a
confirmation, a reply field for an `ask_user_line`. `doc/notifications.md` lays
out the two channels (the optional `desktop-notifier` extra, and OSC 777 / OSC 9
written to `/dev/tty` with no dependency), what each prompt kind offers, why the
reason line stays out by default, and the platform limits — no buttons where a
server does not render them, a reply field only under KDE, kitty left with the
bell.

The example config and the README config block gain the `[notify]` section, and
AGENTS.md documents the three prompt hooks and the rule that only the outermost
prompt of a nested read is announced.
2026-10-21 20:03:36 +08:00
NCBM be8a0111ec core/prompting: answer a question with a notification reply
`ask` now reads its answer from the wait's source as well, which is what makes
the notification's reply field mean something: the reply typed into it is the
answer the caller gets. The readline path stays whenever the source offers no
answers, so line editing and Tab completion only go away when the human asked
for a surface that can answer — and a terminal answer still wins over one that
arrives through the notification.

The read itself is the poll-based one this module already had, so the same
"no deadline, wait for a line or an answer" rule and the same Ctrl+C handling
apply; a cancelled read raises `NonInteractiveError`, as the backend's own
cancellation does.
2026-10-21 20:02:56 +08:00
NCBM 35595d8b04 core/cli: raise the wait signal from every confirm prompt
Every place the CLI blocks on the human now announces itself: the single and
batched tool confirms, the command-denylist and `fetch_local` policies, a skill
write, a directory-access request, the `ask_user_*` questions and the
limit-continue prompt. `_setup_hooks` installs the notifier from `[notify]`, and
only for an interactive run — a one-shot run never waits on a human.

An answer that came back through the notification is read as if it had been
typed: `allow` / `deny` map onto the `y` / `n` the prompts already accept, and
the confirmation prompts switch from click's readline to the poll-based read
only when the notification actually offered answers — otherwise the terminal
experience is untouched. "Go to terminal" never reaches the prompt at all.

The stdin read behind the timed prompts now watches in 100ms slices instead of
one full-length `select`, which is what lets a notification answer end it; an
answer still loses to a human typing at the terminal. A `None` timeout waits
for whichever arrives first, and a non-pollable stdin still gives up at once.

`prompt_wait` announces only the outermost prompt of a nested read: a confirm
that asks a follow-up question while it is already on screen would otherwise
buzz the human a second time.
2026-10-21 20:01:38 +08:00
NCBM 036e0b40ad cli/notify: notify a waiting prompt through the desktop or the terminal
A prompt that has waited `grace_seconds` is now announced, and where the
platform allows it the notification carries the answer: allow / deny / review
for a confirmation, a reply field for an `ask_user_*` question. Picking
"review" withdraws the notification and keeps waiting at the terminal, and a
notification is dismissed as soon as the prompt returns — answered or not.

Two channels. The desktop one is the optional `notify` extra, run on its own
event loop in a daemon thread because a notification's callbacks only fire
while the loop that owns it is running (the library's synchronous wrapper does
not provide that). The terminal one needs nothing: it writes OSC 777 / OSC 9
to `/dev/tty` — so a redirect cannot swallow it — picking the protocol from the
environment and wrapping it for tmux / screen, falling back to a plain bell.
kitty speaks OSC 99 only, which is not implemented, so it keeps the bell.

Nothing here can break a prompt: a missing extra, a desktop that refuses, a
failed write all fall through to the next channel and never raise. Titles are
flattened and stripped of control characters, and the reason line (which can
name a command or a URL) only travels with `include_reason`.
2026-10-21 19:57:17 +08:00
NCBM 10402f5be6 core/config: add the [notify] section
`[notify]` decides what the CLI does when a prompt is waiting on the human:
`grace_seconds` (default 5s, roughly Claude Code's permission notice) before
anything is shown, `min_interval_seconds` between two notifications,
`channels` (`auto` = desktop then terminal), `actions` for the allow/deny/review
buttons, `include_reason` for the one-line reason, and `protocol` to pin the
terminal escape protocol.

The reason stays out by default because it names commands, argv and URLs, and a
notification outlives the prompt and may be mirrored to a lock screen. Unknown
keys degrade to the defaults, the way `[mcp]` does, and `reload()` re-reads the
section.

Desktop notifications need the optional `notify` extra (`desktop-notifier`),
which is the only new dependency and stays off the default install.
2026-10-21 19:53:19 +08:00
NCBM 557300ff4f core/prompting: let a host notify and answer a waiting prompt
A prompt that blocks on the human now announces itself through one process-wide
hook: `prompt_wait(kind, title, detail)` wraps `ask`, `ask_secret`, `choose` and
`confirm` and yields the answer source the host offers. `kind` names the prompt
family (`ask`, `secret`, `choose`, `confirm`, and the CLI's own `policy` /
`access` / `skills` / `limit`) so a host can pick a surface, while `title` stays
short enough to show anywhere and `detail` carries one line the caller has
already redacted. No hook installed means no behaviour change, which keeps
library hosts and the test suite exactly as they were.

The read side of the seam is `read_line_with_timeout`: an optional `source` ends
the read with the host's answer (echoed on the prompt line like a typed one) and
a `None` timeout now means "wait until a line or an answer arrives" instead of
giving up. Stdin keeps priority, so a human typing at the terminal is never
raced by a notification answer that shows up at the same moment.

`reset_prompting` clears the hook along with the output hook and the pause
factory.
2026-10-21 19:52:03 +08:00
NCBM aafbc207a3 test: make the suite run under Windows
Windows spells a path with backslashes, and three fixtures took the POSIX shape: a config value written into a TOML basic string read `C:\users\…` as an escape, tool arguments built with an f-string broke the same way, and a root was compared as `str(path)` against `/srv/skills`. Paths are encoded with `json.dumps` (whose escaping is valid TOML and valid JSON) and compared as `Path` objects, so the same root matches in either spelling.

`test_interrupt.py` delivers SIGINT with `os.kill(pid, SIGINT)`, which is `TerminateProcess` on Windows, and the router itself needs `add_signal_handler`: the module is skipped there.

`python scripts/wine_pdm.py ci` is green: ruff, basedpyright 0 errors, 1156 passed / 30 skipped.
2026-10-21 19:28:49 +08:00
NCBM 4c597274e7 docs: describe YOLO skipping YOLO-tagged confirms
Session YOLO skips the soft confirms of `YOLO`-tagged tools — interpreter runs included — while untagged tools keep prompting and a command that escalates privileges keeps its own confirm through `DangerReason(yolo_skip=False)`. A plugin now also sees that answer shape where the danger classifier is documented, and the round's aggregated prompt leaves the covered calls out.
2026-10-21 19:10:56 +08:00
NCBM 8ec63aa6a2 core/agent: let YOLO mode skip YOLO-tagged soft confirms
`--yes` / `/yolo on|off|once` clears the prompt of a `YOLO`-tagged tool: interpreter runs (`run_argv`, `run_argv_batch`, `open_pty`), the file mutations (overwrite / copy / move / delete) and `fetch_local`'s cleartext and mutating requests. A tool without the tag still asks, and hard path/command denylists stay untouched.

`classify_danger` now answers with a `DangerReason` — the reason text plus `yolo_skip` — so one call can refuse that skip: a command that runs through `sudo` / `su` / `doas` / `pkexec` (as the program or a wrapper) keeps its confirm whatever YOLO mode says. It never adds a reason of its own, so the program the denylist gate already asks about is not asked twice.

`prepare_batch_confirm` leaves the covered calls out, so a skipped call neither prompts on its own nor takes a slot in the round's aggregated prompt, and one escalating step keeps the whole batch in front of the human.
2026-10-21 19:10:46 +08:00
NCBM 029ac47350 docs: describe image input across the three surfaces
`doc/images.md` holds the message shape, how `@path` attaches a file, the
per-surface mapping, the provider limits, what a live probe against DeepSeek
confirmed (`tool_result` and `function_call_output` images work, `detail: low`
is 3.4x cheaper, an assistant-message image is a 400), and why the Files API is
not used.

AGENTS.md gets the invariants and the mention/config keys; `doc/architecture.md`
and the example TOML point at it.
2026-10-21 18:53:47 +08:00
NCBM 154509b929 core/cli+config: attach files to a turn with @path mentions
Typing `@shot.png` in the line attaches that picture to the turn — no separate
command — and `@notes.txt` appends the file as text under a `--- @path ---`
header, capped like a tool result with a resumable `truncate_token`.
`@"my shot.png"` is the quoted form, and Tab completes the path after `@`
(directories end in `/`, a name with a space completes quoted).

An image is skipped, with a note naming the key to set, on a model without
`image_in`; a missing or denylisted path is a note too, and the turn still goes.
The default catalogs mark what can take a picture (`deepseek-flash` yes,
`deepseek-v4-pro` no, the OpenAI defaults yes).

`Agent.run` takes the parts, so the turn is stored with its images and shows up
as `[image]` wherever only prose is wanted.
2026-10-21 18:53:47 +08:00
NCBM e9ee71e853 core/tools: show images instead of describing them
New `view_image` reads a local picture (JPEG, PNG, GIF, WebP, sniffed from its
bytes) and hands it to the model as an image part, with an optional `detail`
level — `low` measured 3.4x cheaper on a 1200x900 picture. `read_file` now
points at it instead of returning decode noise.

`fetch_local` returns an image response as the picture itself, and MCP image
content blocks become image parts instead of `[image: ...]` placeholders, so a
screenshot server finally reaches the model.
2026-10-21 18:43:29 +08:00
NCBM c6a94e0b66 core/agent: let a tool result carry images beside its text
`ToolOutput` pairs the model-facing text with the images a tool produced.
`ToolRegistry.execute` keeps its text-only contract; `execute_output` returns
the pair and the loop stores the images on the tool message, so a screenshot
tool can answer in pixels instead of a hex placeholder.

Only the text is capped by the tool-result limit; the char estimator now counts
a present image at its 1024-token ceiling, because the shrinkers never drop one.
2026-10-21 18:43:29 +08:00
NCBM d7293eeb16 core/lmproto+agent: carry image parts on user and tool messages
A user turn's `content` may now be a part array (`ChatTextPart` +
`ChatImagePart`), which chat completions take as-is; the Anthropic and
Responses bridges translate it into `image` blocks and `input_image` parts.

A tool result keeps its text in `content` and carries `images` beside it:
Anthropic and Responses put them in the result item itself (a live probe reads
both back), while chat completions cannot, so
`compat.chat_messages_with_images` moves them into the user turn after the tool
run. Prose-only readers go through `content_to_text`, which renders an image as
`[image]`.

`agent.images` sniffs the real format from the bytes and builds the data URL,
capped at 8 MiB inline because every stored message is re-sent on each round.
2026-10-21 18:43:29 +08:00
85 changed files with 4716 additions and 406 deletions
+9 -6
View File
@@ -60,7 +60,7 @@ lmproto/deepseek/anthropic/ ← DeepSeek Anthropic client (extends Anthropic
### Config (`config/`)
TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]` section. Default path via platformdirs. Providers may set `timeout` as a float or `{ connect, read }` (`HttpTimeoutConfig`); omitted → product defaults (10s connect / 600s read). `DeepseekProvider.convention` (Literal: `""`/`"openai"`/`"anthropic"`/`"responses"`) selects the DeepSeek API surface — `"responses"` → OpenAI Responses, `"anthropic"` → Anthropic Messages on `https://api.deepseek.com/anthropic` (default url follows the convention); a per-model `ModelConfig.convention` overrides it (empty = inherit). `[mcp]` (`McpConfig`): `servers.<name>` stdio server definitions (`McpServerConfig`: `command`/`args`/`env`/`cwd`/`timeout`/`read_only`, `url` reserved for streamable HTTP) + `disable` gate; defining a server opts it in. `[skills]` (`SkillsConfig`): `paths` (explicit roots first), `discover` (`plyngent` user dir + `claude` dirs) with `include_project_roots`, `enabled`, and `allow_write` (standing write grant); a malformed section degrades to defaults like `[agent]` / `[mcp]`.
TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]` section. Default path via platformdirs. Providers may set `timeout` as a float or `{ connect, read }` (`HttpTimeoutConfig`); omitted → product defaults (10s connect / 600s read). `DeepseekProvider.convention` (Literal: `""`/`"openai"`/`"anthropic"`/`"responses"`) selects the DeepSeek API surface — `"responses"` → OpenAI Responses, `"anthropic"` → Anthropic Messages on `https://api.deepseek.com/anthropic` (default url follows the convention); a per-model `ModelConfig.convention` overrides it (empty = inherit). `[mcp]` (`McpConfig`): `servers.<name>` stdio server definitions (`McpServerConfig`: `command`/`args`/`env`/`cwd`/`timeout`/`read_only`, `url` reserved for streamable HTTP) + `disable` gate; defining a server opts it in. `[skills]` (`SkillsConfig`): `paths` (explicit roots first), `discover` (`plyngent` user dir + `claude` dirs) with `include_project_roots`, `enabled`, and `allow_write` (standing write grant); a malformed section degrades to defaults like `[agent]` / `[mcp]`. `[trash]` (`TrashConfig`): `enabled` — whether `delete_path` moves to the system trash or removes the path outright; a typo degrades to the default (trash on).
`reasoning.py`: thinking strength as `reasoning_effort` (`ReasoningEffortConfig`: `""` = send nothing | `none` … `high` … `max`) and `thinking_budget_tokens`, settable on `[agent]`, a provider, or a model entry — `resolve_reasoning(agent, provider, model)` merges the layers per field (model > provider > agent) and clamps `max` (DeepSeek chat only) to `xhigh` for every other surface, returning a `ReasoningConfig` the agent loop and `create_client` consume.
@@ -83,8 +83,9 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
- **Kind-based dispatch**: clients expose `kind` (`chat_completions` | `responses` | `messages`). Agent history stays chat-completions-shaped; transport adapters return synthetic `ChatCompletionResponse` / stream chunks.
- **`responses_bridge` / `responses_dispatch`**: chat ↔ OpenAI Responses; stream text/reasoning deltas (`response.reasoning_text.done` repeats what its fragments spelled out, so the loop streams each part once and still keeps the whole text in the round's message); tool calls + usage on `response.completed`; a request carrying `tools` returns every round's reasoning as a `reasoning` item of `reasoning_text` parts — "必须完整回传 `reasoning_content` … 即使该轮模型未实际进行工具调用" (a missing item and an empty part are both a 400, so a round that kept none sends a blank line) — and a round's `function_call` items stay contiguous ahead of their outputs (`call, output, call, output` was a 400: the second call reads as a round of its own without reasoning); reasoning is read back from the item's `content` parts.
- **`messages_bridge` / `messages_dispatch`**: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments; `thinking` from the request's `reasoning_effort` (level → token budget, floor 1024, `max_tokens` grown when the budget would leave no room) plus the client's configured `thinking_budget_tokens`; a thinking block the model refuses (from Claude 4.7 on, `thinking.type.enabled` is a 400 asking for adaptive thinking + `output_config.effort`; a model whose thinking is always on refuses `thinking.type.disabled`) gets one retry with the shape it takes — adaptive thinking with the level as the effort, or no block at all — and the refusal is remembered per model on the client, so only its first request pays the round trip (only a refusal that arrives before the stream yields anything is retried; a later failure has already been seen); a server tool (`server_tool_use` + its `web_search_tool_result`) is the surface's own business — it streams `input_json_delta` fragments like a call but nothing local asked for it, so only a `tool_use` block becomes a tool call and nothing is executed; `thinking` and the two search blocks are kept as `AssistantChatMessage.provider_blocks` (the thinking text also lands in `reasoning_content`, so it streams as reasoning) and go back with the round, which is also why a round that kept only those blocks is not an empty completion; a chat-completions request drops the field (`compat.coerce_chat_messages`).
- **Images** (see `doc/images.md`): a user turn's `content` may be a part array (`ChatTextPart` + `ChatImagePart`), and a tool result carries its pictures in `ToolChatMessage.images`; each surface lifts them into what it can express — chat-completions content blocks (tool-result images move into the user turn after the tool run, since a `tool` message holds text only), Anthropic `image` blocks (inside `tool_result` for a tool result), Responses `input_image` parts (in a `function_call_output` item). Pictures belong to user turns: an image in a system or assistant message is a provider 400. `agent/images.py` sniffs the format from the bytes and caps an inline picture at 8 MiB, because every round re-sends it; `[providers.*.models.*].image_in` is the opt-in.
- **Provider-side tools**: `provider_tools` on the OpenAI **and** DeepSeek presets (list of dicts; `[{type="web_search"}]` when omitted; `[]` disables) threaded via `ChatAgent`/`run_chat_loop` into Responses and Messages; never executed by `ToolRegistry`. The Messages surface declares its own server tool instead — a bare `web_search` becomes `{type="web_search_20250305", name="web_search"}` (the search then runs inside the response, and its `encrypted_content` results go back verbatim with the round — `AssistantChatMessage.provider_blocks`) — and a DeepSeek provider on a surface that ignores hosted tools (`config.routing.local_web_search`) gets the local `web_search` tool from `tools/net/search.py` instead.
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name. Before running, `execute` answers a call that omits a required argument with the accepted names (`_missing_required_error`) — the handler's bare `TypeError` never reaches the model, and no confirm prompt fires for a call that cannot run. Shape mistakes inside a *present* argument are the tool's own job (see the `chat` bullet). `@tool(writes=("path",))` names a handler's file arguments: one batch applies calls that name the same file one at a time, in call order — the lock is taken before the confirm prompt, so a pending prompt cannot be overtaken — while different files keep running in parallel.
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name. A handler returns its text, or a `ToolOutput(text, images)` when it also produced a picture: `execute` keeps the text-only contract and `execute_output` returns the pair, which `run_chat_loop` stores as the tool message's text plus its `images` (`view_image`, an image body from `fetch_local`, and an MCP `image` content block all take that path). Before running, `execute` answers a call that omits a required argument with the accepted names (`_missing_required_error`) — the handler's bare `TypeError` never reaches the model, and no confirm prompt fires for a call that cannot run. Shape mistakes inside a *present* argument are the tool's own job (see the `chat` bullet). `@tool(writes=("path",))` names a handler's file arguments: one batch applies calls that name the same file one at a time, in call order — the lock is taken before the confirm prompt, so a pending prompt cannot be overtaken — while different files keep running in parallel.
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`; resolved `reasoning_effort` on every request (see Config). A round the provider cut off at the output limit is kept, not failed: the answer and its thinking blocks stay in `messages`, the round's tool calls are dropped (a half-written call must not run — the model re-issues it), and up to `max_truncation_continues` (2) further rounds carry on from a truncation notice; 0 spends the turn as unfinished instead, for `/retry`.
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `provider_tools`; `retry()` continues incomplete turns (user-only, after committed tools — does not re-run those tools — or after a kept truncated answer) and `pending_retry_text` reads a truncation notice as unfinished work.
- **`/compact`**: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first. The default summarizer system prompt states that tool calls are unavailable (a `compact_system_prompt` override replaces that default wholesale).
@@ -98,11 +99,11 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots **or** a directory-access grant (mode-aware: `read` < `write` < `exec`, default required `write`; a file grant matches only that file); path substring denylist (wins over grants); command denylist via `match_command_denylist` (checks every program of the wrapper/`run` chain, resolved by `tools/command_scan.py`; an entry is a program, plus optional argument words that make it dangerous — `systemctl reboot poweroff …` matches `systemctl reboot` and `systemctl start reboot.target`, not `systemctl status`; basenames are lower-cased, so `RM` / `rm.exe` count; defaults cover privilege `sudo`/`su`/`doas`/`pkexec`/`run0`/`systemd-run`, destructive `rm`/`mkfs`/`dd`/`chown`/`mount`/…, and power state `shutdown`/`reboot`/`poweroff`/`halt`/`init`/`telinit`/`kexec`/`pm-*`/`s2*`; CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); escape error names the `request_directory_access` mode the call needs (`call request_directory_access with mode='write'`).
- **`access`** (`tools/access.py`): `request_directory_access` (LOCAL|INSTANCE|SESSION) asks the host hook for out-of-workspace access; grants are one `path → mode` pair in one of three stores — `WorkspacePolicy.config_allow` (static `[agent].allow_paths`), `SessionState.access_grants` (human-approved, persisted on the session row), `WorkspacePolicy.yolo_allow` (`--yes`/`/yolo`, process only). `AccessDecision(mode, persist)` from the hook; denylisted paths can never be granted; cap `MAX_ACCESS_GRANTS`. Not a sandbox: `exec` grants (and any command) reach the whole FS.
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range), `write_file` (refuses to replace an existing file unless `overwrite=true`; that replacement is the one soft-confirmed), `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `regex_files` (Python `re` per line, not a glob; literal file/dir `path`, no glob expansion; skips VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path`, `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume any truncated result via its `truncate_token=...`).
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range; an image file is refused with a pointer to `view_image`), `view_image` (a picture reaches the model as an image part; format sniffed from the bytes, `detail` = `auto` default / `low` / `high` / `original`), `write_file` (refuses to replace an existing file unless `overwrite=true`; that replacement is the one soft-confirmed), `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `regex_files` (Python `re` per line, not a glob; literal file/dir `path`, no glob expansion; skips VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path` (moves the path into the system trash — freedesktop trash / Finder trash / Recycle Bin — so a delete can be undone; `permanent=true`, `[trash] enabled = false`, or a path under the OS temp dir delete it for good, and a trashing that fails reports the reason and leaves the path alone; [doc/trash.md](doc/trash.md)), `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume any truncated result via its `truncate_token=...`).
- **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_batch` (serial steps, `pipe_out` on provider, `mix_stderr`, `stop_on_error`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (escapes) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`\xHH`, `ctrl+x`, `key=esc`); secrets via `ask_into_pty` (not `write_pty` data); session limit/idle TTL/output budget; close terminate→kill.
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget. The prompt accepts `y`/empty (once), `n`, or `yyy` = stop asking for the rest of the turn (`cli.limits` module flag, reset by `run_turn_with_retries`, `/btw`, session switches, and chat exit). `--auto-continue` / `[agent] auto_continue_limits` set the process-wide default (covers one-shot/PTY); the flag does not imply YOLO and vice versa.
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for interpreters — bare shells/REPLs, scripts, and `-c` one-liners alike (`-c` is code only for an interpreter, so `grep -c` / `od -c` stay unflagged; argv + code preview) —, detached runners (`nohup`), and wrapper options that carry a command line (`env -S "…"`, `su -c "…"`, whose text is printed under its own `env -S:` label), each resolved past env wrappers and `<launcher> run`; deny may include a user comment for the model. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn). A round of tool calls asks **once**: `ToolRegistry.prepare_batch_confirm` pre-scans it (unknown tools, undecodable arguments, and calls missing a required argument are left out — nobody is asked about a call that cannot run), two or more soft reasons collapse into one `ToolBatchConfirmHook` prompt whose `BatchConfirmDecision` `execute(..., decision=…)` applies to each call (CLI box lists every call: `y` = allow these, `m` = review them one at a time through the per-call prompt, `N` = deny with one comment shared by all), the answer covers that round only (the next one asks again), and a lone request keeps the plain one-call prompt.
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for interpreters — bare shells/REPLs, scripts, and `-c` one-liners alike (`-c` is code only for an interpreter, so `grep -c` / `od -c` stay unflagged; argv + code preview) —, detached runners (`nohup`), and wrapper options that carry a command line (`env -S "…"`, `su -c "…"`, whose text is printed under its own `env -S:` label), each resolved past env wrappers and `<launcher> run`; deny may include a user comment for the model. The `delete_path` confirm names what will happen — `move path 'x' to the system trash`, or `delete path 'x' permanently` when the call asks for it, the host cleared `[trash]`, or the path is scratch — and the pretty line prints `(to trash)` for a delete that landed there. Session YOLO: `/yolo on|off|once` and `--yes` (skip the soft confirms of `YOLO`-tagged tools — interpreter runs included — while untagged ones still prompt and a command that escalates privileges keeps its confirm through `DangerReason(yolo_skip=False)`; hard denylists unchanged; `once` expires after the next user turn). A round of tool calls asks **once**: `ToolRegistry.prepare_batch_confirm` pre-scans it (unknown tools, undecodable arguments, calls missing a required argument, and calls YOLO already covers are left out — nobody is asked about a call that cannot run or will not ask), two or more soft reasons collapse into one `ToolBatchConfirmHook` prompt whose `BatchConfirmDecision` `execute(..., decision=…)` applies to each call (CLI box lists every call: `y` = allow these, `m` = review them one at a time through the per-call prompt, `N` = deny with one comment shared by all), the answer covers that round only (the next one asks again), and a lone request keeps the plain one-call prompt.
- **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems.
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core); `wait` (line prompt with timeout; Enter disturbs). Container args are shape-checked (`tools/chat/shape.py`): a mis-typed string `options`/`fields` (or a non-string `question`) returns an explained error — `options="a, b"` used to iterate characters into a per-character menu. `wait`'s `duration` accepts numeric strings (`"5"` = 5s) and rejects booleans/text/containers.
- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by the number a listing shows (render order, TOP group first; the stored `id` stays internal); DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work (`[TODO OPEN WORK]`); all-terminal non-empty = hygiene (`[TODO HYGIENE]`); nag channel via `[agent] todo_nag_strategy` (`developer`/`user` = prose nag; `synthetic_tool` = forged `todo_list` call + **real** `stack.render()` result + ToolCall/Result events; `none`).
@@ -110,18 +111,20 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
- **`net` / `web_search`** (`tools/net/search.py`, `tools/net/bing.py`): the search a DeepSeek provider cannot host, as a local tool. `build_web_search_tool(backends)` closes over the sources named by `provider.search_sources` and asks them **in fallback order** (`search_in_order`): `DeepseekSearchBackend` (one `POST /v1/tools/search` through `lmproto/deepseek/webtools/`, no model round — `deepseek_api_base_url` drops the convention's `/anthropic` and any `/v1` before the client appends its path; 408/429/5xx are retried 0.5s/1.0s, 402 and other 4xx are not) and/or `BingSearchBackend` (Bing's RSS results, snippets included, fetched through the same client as `fetch_local`; opt in with `search_sources = ["deepseek", "bing"]` — the default is `["deepseek"]`). Hits are `SearchHit` from `lmproto/search.py` (DeepSeek's `snippet`, else its `summary` cut to 200 chars); the handler returns one `title — url` entry per hit (plus the snippet when the source has one), and a source that raises or finds nothing is skipped and reported only if every source fails. Registered by the CLI host only when `config.routing.local_web_search` says so: a DeepSeek provider with a non-empty `search_sources`, unless the Anthropic convention already hosts the search (`convention = "anthropic"` **and** `provider_tools` ask for `web_search`) — so `provider_tools = []` brings the local tool back on that surface too. `provider_tools` owns the hosted tool, `search_sources` owns this one. Tags LOCAL|READ_ONLY.
- **`mcp`** (`tools/mcp.py`): register one server's advertised tools into the catalog as namespaced `mcp__<server>__<tool>` definitions with `ToolSource(kind="mcp", plugin_id=server)`; tags LOCAL (+ READ_ONLY when the server config sets `read_only`) — never YOLO/TRUSTABLE. `register_mcp_tools` purges prior MCP entries then re-registers every connected server (hosts call it on tool-registry rebuild); connections must already be started by `McpManager`.
- **`skills`** (`tools/skills.py`): discovery, reading, and writing of skill directories (`plyngent.skills` owns roots, the `SKILL.md` frontmatter subset, the uncached store, and the inside-the-skill path rule). `skill_list` (names, source root, directory, description, shadowed copies, frontmatter notes), `skill_read` (a `SKILL.md` by name, or one bundled file — reuses `read_file` for ranges/line numbers/`truncate_token`), `skill_search` (regex over every visible skill via `regex_files`, hits rebased on `<skill>/<file>`), `skill_create` (new `SKILL.md` + optional `files`), `skill_edit` (replace / `append` / rewrite one file; a rewritten `SKILL.md` is re-parsed and notes a lost description). Reads need no grant (the host publishes the roots as `WorkspacePolicy.static_read`, so `read_file`/`regex_files` see them while writes stay denied); writes go through **`SkillWritePolicy`** on the instance — denylist first, then `SessionState.skill_write_roots` (a `session` answer), then `[skills].allow_write`, then the host's confirm hook (CLI `prompt_skill_write_confirm`: `o`nce/`s`ession/`n`o, timed, default deny, **not** skipped by YOLO); a host with no hook denies. Grants are per **skill directory**; `[skills].enabled = false` drops the tools from the registry (`SKILL_TOOL_NAMES`).
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + skills + net (`fetch_local`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `get_truncated`, `listdir`, `glob_paths`, `regex_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `skill_list`/`skill_read`/`skill_search`, `fetch`, `fetch_local`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch_local` accept GET only.
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + skills + net (`fetch_local`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `view_image`, `get_truncated`, `listdir`, `glob_paths`, `regex_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `skill_list`/`skill_read`/`skill_search`, `fetch`, `fetch_local`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch_local` accept GET only.
### Prompting (`prompting.py`)
Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable backend; non-TTY uses defaults or errors. CLI limit/confirm hooks and chat tools both use this. Async helpers serialize prompts (`run_prompt_async`).
Three process-wide hooks let a host take over a prompt: `set_prompt_output_hook` (break an open pretty line before the prompt draws), `configure_prompting(pause_factory=…)` (the SIGINT guard around an off-loop read), and `set_prompt_wait_hook` — a prompt that blocks announces itself through `prompt_wait(kind, title, detail)`, which yields the `PromptAnswerSource` the read may consult (`take()` returns `allow` / `deny` / reply text without blocking) or `None` for notify-only. Only the outermost prompt of a nested read is announced; with no hook installed nothing changes. `read_line_with_timeout` accepts a `source` and a `None` deadline, and stdin always wins over a host answer. The CLI's implementation is `cli/notify.py` (`[notify]`, desktop via the optional `desktop-notifier` extra, terminal via OSC 777/9 to `/dev/tty`): a prompt is announced only after `grace_seconds`, the answer is treated like a typed one (never a session grant), and every failure falls through to the next channel instead of raising.
### CLI (`cli/`)
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data when url unset; explicit `url = ":memory:"` kept + warn); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--auto-continue` (auto-raise limits), `--stream/--no-stream`, `--quiet`. Root `--log-level`. Enabled `[mcp]` servers start before the REPL (`_start_mcp_manager`, per-server failures warn on stderr) and are closed on exit (`McpManager.aclose`); each server spawns in its own session/process group (`start_new_session` / `CREATE_NEW_PROCESS_GROUP`), so a terminal Ctrl+C does not kill it.
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. `/history` is turn-oriented (`cli/transcript.py`): a turn runs from a user row to the next one, rows carry **display ordinals** (never DB row numbers; restart with the session), the turn's ends (user + final answer) always print in full, `-v` expands every row (one-liners), `-vv` prints full bodies + local rows, `--preview` collapses every row, and `N`/`last [N]` address turns while `--message N` addresses one row. Multiline `"""` … `"""`; `/edit` via `$VISUAL`/`$EDITOR` (blocking only). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack. `/skills` (`list` default) prints the discovered skills with their roots, shadowed copies, and frontmatter notes; `/skills search PATTERN` runs the matching tool handler (the slash path binds the tool context itself); `/skills open NAME` reveals the skill's directory in the system file manager. `/mcp` (`list` default) prints per-server status/tool counts + an `instructions` preview when the server sent usage guidance, + last stderr lines; `/mcp reconnect` re-reads config, restarts every enabled server, and rebuilds the tool registry. `/grants` lists directory-access grants (`revoke <index|all>`; config grants point at the TOML); `/status` includes the grant count and the visible skill count (or `off` when `[skills].enabled` is false).
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. `/history` is turn-oriented (`cli/transcript.py`): a turn runs from a user row to the next one, rows carry **display ordinals** (never DB row numbers; restart with the session), the turn's ends (user + final answer) always print in full, `-v` expands every row (one-liners), `-vv` prints full bodies + local rows, `--preview` collapses every row, and `N`/`last [N]` address turns while `--message N` addresses one row. Multiline `"""` … `"""`; `/edit` via `$VISUAL`/`$EDITOR` (blocking only). An `@path` mention in the typed line attaches that file to the turn (`cli/mentions.py`): an image becomes an image part (skipped with a note when the model has no `image_in`), any other file is inlined under a `--- @path ---` header, capped like a tool result with a resumable `truncate_token`; `@"path with spaces"` is the quoted form, and Tab completes the path (quoted when it holds a space, directories end in `/`). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack. `/skills` (`list` default) prints the discovered skills with their roots, shadowed copies, and frontmatter notes; `/skills search PATTERN` runs the matching tool handler (the slash path binds the tool context itself); `/skills open NAME` reveals the skill's directory in the system file manager. `/mcp` (`list` default) prints per-server status/tool counts + an `instructions` preview when the server sent usage guidance, + last stderr lines; `/mcp reconnect` re-reads config, restarts every enabled server, and rebuilds the tool registry. `/grants` lists directory-access grants (`revoke <index|all>`; config grants point at the TOML); `/status` includes the grant count and the visible skill count (or `off` when `[skills].enabled` is false).
- `/btw` side questions: forked session; main transcript/DB untouched. `--tools=read` (default) exposes only `READ_ONLY` tools under `read_only_context()` (fetch=GET) with the main session view; `--tools=no` off; `--tools=full` whole-registry clone with fresh session. Tools off: default falls back to no tools; explicit `read`/`full` errors. The model gets an `aside` notice as its last message before the question (unsaved exchange + tool scope), shown to the user as `[notice] aside: …`.
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**. A session resumed by a **freshly started process** (`--session` / default latest) also gets a `resume` notice about what a restart drops (PTY sessions, running commands, temp workspaces) — resuming inside a live process pushes nothing.
- Host notices (`agent/notices.py`): system-level messages appended as trailing `developer` rows, never system-prompt edits — `Notice.to_message()` for the model, `.summary` for the user via `cli.display.echo_notice` (`--quiet` hides the user line only). Durable hosts persist them (`ChatAgent.push_notice`); a side agent without memory keeps its notice local. `has_notice()` dedupes a session-scoped notice: the `skills` one is pushed once per session (feature enabled + tools on), lists nothing, and makes the model ask with `skill_list` / `skill_search`. Trailing developer messages never end a turn, so `/retry` still sees an interrupted turn behind a notice or checkpoint.
+17
View File
@@ -173,6 +173,19 @@ max_context_tokens = 200000
# enable = ["acme"]
```
```toml
# Notifications while a prompt waits on you. See doc/notifications.md.
# [notify]
# grace_seconds = 5.0 # announce only after this long
# channels = ["auto"] # auto | desktop | terminal
# actions = true # allow / deny / review buttons + reply field
# include_reason = false # off: the reason may name a command or URL
# Deletes: `delete_path` moves the path to the system trash. See doc/trash.md.
# [trash]
# enabled = true # false = a delete is final again
```
Per-provider **`timeout`** is passed to the HTTP session for chat/completions, Responses, and `GET /models`. A single number sets one timeout; `{ connect, read }` splits TCP/TLS setup vs idle wait between response bytes (SSE can run longer than `read` while chunks keep arriving). Tool/process timeouts (`run_argv`, PTY, policy confirm) are separate.
**Thinking strength** (`reasoning_effort`) can be set on `[agent]`, on a provider, or on a single model: the most specific layer wins, per field, so a model may override only the effort or only the budget. `""` sends nothing and lets the provider apply its own default. Each API surface maps the value onto its own wire field — chat completions send `reasoning_effort`, Responses send `reasoning.effort`, and Anthropic has no effort level at all, so it sends `thinking` with the token budget that level maps onto (`minimal` 1024 … `xhigh`/`max` 32768; `none` disables thinking); a thinking block the model refuses is retried once with the shape it takes — adaptive thinking with the level itself as `output_config.effort` (from Claude 4.7 on), or no block at all where thinking cannot be turned off — and the refusal is remembered for the session, so `thinking_budget_tokens` is ignored on such a model. `max` is DeepSeek's own top level on chat completions and clamps to `xhigh` everywhere else. `thinking_budget_tokens` overrides that mapping; either way the request stays valid: the budget never drops below 1024, and when it would swallow the output limit `max_tokens` grows so the answer still has room. DeepSeek chat keeps `thinking.type = "enabled"` unless the effort is `none`.
@@ -193,6 +206,10 @@ Third-party **plugins**: install a package that declares `project.entry-points."
**Skills** (instruction directories with a `SKILL.md`): discovered under `[skills].paths`, our own user skills directory, and other harnesses' skill directories (`~/.claude/skills`, plus `<workspace>/.claude/skills` when `include_project_roots`). A session-start notice (a host notice, never a system-prompt edit) tells the model that the skill tools exist and how to use them — `skill_list` to see what exists, `skill_read` for a `SKILL.md`, `skill_search` for content — and deliberately lists nothing; `skill_create` / `skill_edit` write one behind a timed `once`/`session`/`no` grant (`[skills].allow_write = true` for a standing one; the path denylist still wins and `--yes`/`/yolo` never skips it). Reading needs no grant: skill roots are read-only roots, so `read_file` / `regex_files` see them too while writes stay denied. Details: [doc/skills.md](doc/skills.md).
**Notifications while a prompt waits**: a turn stops at a confirm, a directory-access request or an `ask_user_*` question, so if you walked away you only find out on your return. After `[notify].grace_seconds` (5s) the prompt is announced — with **Allow once / Deny / Go to terminal** buttons for a confirmation and a reply field for a question, where the platform renders them — and the answer is taken as if typed at the terminal (never as a session grant). The desktop channel needs the optional extra (`pip install 'plyngent[notify]'`); without it the CLI writes an OSC 777 / OSC 9 escape sequence to `/dev/tty`, which every modern terminal turns into a native notification with no dependency at all. Answering at the terminal inside the grace period stays silent, `[notify].include_reason` keeps the reason line (a command, argv or URL) out by default, and no channel can break a prompt: a missing extra or a refusing desktop falls through to the next one. Details: [doc/notifications.md](doc/notifications.md).
**Deletes go to the trash**: `delete_path` moves the path into the desktop's own trash — the freedesktop trash on Linux, the Finder trash on macOS, the Recycle Bin on Windows — so a delete the agent made on its own can be undone from the file manager you already use. That matters because the tool is YOLO-tagged: under `--yes` / `/yolo on` it runs without a confirmation. Ask for the old behaviour with `permanent=true`, or turn it off for every call with `[trash] enabled = false`. Paths under the system temp directory are scratch and are deleted outright, a trashing that fails (a volume with no usable trash directory, a broken install) leaves the file alone and says so instead of deleting it, and a shell `rm` never uses the trash. Details: [doc/trash.md](doc/trash.md).
**MCP servers** (Model Context Protocol over stdio): define servers under `[mcp.servers.<name>]` (`command` + `args`, optional `env`/`cwd`/`timeout`/`read_only`) and their tools become agent tools namespaced `mcp__<server>__<tool>` (LOCAL tags; `read_only = true` also marks them READ_ONLY, eligible for `/btw --tools=read`). Names in `[mcp].disable` stay disconnected. A server may also return optional `instructions` (usage guidance) in its MCP `initialize` response; when tools are on those are folded into the agent system prompt as `MCP server <name> instructions:` blocks (`[agent] mcp_instructions = false` disables), and `/mcp` previews them. In the REPL, `/mcp` lists per-server status and tool counts; `/mcp reconnect` re-reads the config file and restarts every enabled server (adopts newly added ones).
Supported provider presets today:
+10 -2
View File
@@ -32,11 +32,19 @@
``@tool`` / tags / registry. History stays chat-completions-shaped.
- tools: Workspace file/process/VCS/chat/todo/net/skill tools + directory-access
grants (`access`); catalog; plugins; instance/session context and views.
- prompting: Shared ask/choose/form for CLI and tools.
- cli: Click entry, slash registry, REPL, one-shot chat.
- prompting: Shared ask/choose/form for CLI and tools; the hooks a host uses
to take over a prompt (output break, SIGINT guard, waiting notification).
- cli: Click entry, slash registry, REPL, one-shot chat; `notify` announces a
prompt that waits on the human.
- web: Web service (Phase H; not implemented).
Plugins (third-party entry points, allowlisted under ``[plugins]``): [plugins.md](./plugins.md).
Conversation units (a *round* = one request→response plus its tool batch, a
*turn* = a user message to the explicit stop): [turns-and-rounds.md](./turns-and-rounds.md).
Images (a turn's pictures, a tool result's pictures, what each wire surface
takes): [images.md](./images.md).
Prompt notifications (a prompt that waits on the human, and how it can be
answered from the notification): [notifications.md](./notifications.md).
Deletes (a `delete_path` call moves the path into the system trash, and what
that does not cover): [trash.md](./trash.md).
+108
View File
@@ -0,0 +1,108 @@
# Images
A model that takes pictures can look at a screenshot, a chart, or a diagram
instead of reading a description of it. plyngent carries images as content
parts on messages; each wire surface lifts them into its own shape.
## Shape
`lmproto/openai_compatible/model.py`:
| Type | Meaning |
|---|---|
| `ChatImageUrl(url, detail)` | one picture: an `http(s)` link or an inline `data:` URL |
| `ChatImagePart(image_url)` | that payload as a `{"type": "image_url"}` block |
| `ChatTextPart(text)` | the text block beside images |
| `UserChatMessage.content` | `str`, or `list[ChatTextPart \| ChatImagePart]` when the turn carries pictures |
| `ToolChatMessage.images` | the pictures a tool returned, beside its text `content` |
Prose-only readers go through `content_to_text`, which renders each image as
`[image]` (summaries, transcripts, exports, the retry preview).
`agent/images.py` owns the bytes: it sniffs the format from the file's content
(JPEG `ff d8 ff`, PNG, GIF, WebP `RIFF….WEBP`), builds the data URL, and caps an
inline picture at `MAX_INLINE_IMAGE_BYTES` (8 MiB) — far below what a provider
accepts, because the message is re-sent every round and lives in the session's
JSON message column.
## Per surface
| Surface | A user turn's images | A tool result's images |
|---|---|---|
| chat completions (`compat.chat_messages_with_images`) | already content blocks | a `tool` message may only carry text, so the images move into the user turn emitted right after the tool run |
| Anthropic `/messages` (`messages_bridge`) | `image` blocks beside the text block | inside the `tool_result` block's content, beside a text block |
| Responses (`responses_bridge`) | `input_text` + `input_image` parts | in the `function_call_output` item's `output` array |
A chat-completions request drops the parts of a message it cannot express; no
history is rewritten (the stored message keeps its own images).
## Limits (provider side)
| Limit | Value |
|---|---|
| Formats | JPEG, PNG, GIF, WebP (sniffed from bytes, never the file name) |
| Request body | 48 MiB |
| One image, inline or URL | 32 MiB |
| One image, Files API `file_id` | 64 MiB |
| Images per request | 600 |
| Image side | 8192 px, or 4096 px when a request holds 15+ images |
| External URL | 8192 characters, downloaded within 60 s |
| Tokens per image | at most 1024 (a picture is resized to ~1300x1300 px, and one below ~544x544 is scaled up) |
Images belong to **user** messages only: a picture in a system or assistant
message is a 400 (`Image in assistant message is unsupported`). A tool result
lives inside a user turn, which is why it can carry one.
## Verified against the DeepSeek API
The 2026-10-17 docs plus a live probe (`deepseek-flash`):
- the same picture is accepted on all three surfaces, and the usage is identical;
- an image inside `tool_result` content works, and the model reads it — the
round that asked for a screenshot got the layout, the colours and the pixel
span back;
- an image inside `function_call_output` works too (the round needs its
`reasoning` item, which `responses_bridge` already sends);
- `detail: "low"` measured 3.4x cheaper than `original` on a 1200x900 picture
(240 vs 708 prompt tokens) with no loss on "which colour where"; the floor is
210 tokens even for a tiny picture, because small images are scaled up;
- re-sending a picture in a later round hits the provider's prefix cache
(`cache_read_input_tokens`), so the "history is paid for again" cost is not
the worst case;
- `/anthropic/messages` and `/anthropic/v1/messages` both answer, so the
client's path stays as it is.
## Attaching one
Type `@path` in the line — no command, no separate step:
```
what is wrong with this layout? @shots/broken.png
```
`cli/mentions.py` reads the token (whitespace-delimited, `@"path with spaces"`
for names that hold one; an e-mail address is not a mention), resolves it against
the working directory, checks the path denylist, and attaches it. An image
becomes an image part; any other file is appended as text under a
`--- @path ---` header, capped like a tool result (its `truncate_token` keeps the
remainder resumable). A problem — a missing file, a denylisted path, a picture a
text-only model cannot take — prints a note and leaves the turn going.
Tab completes the path after `@` (directories end in `/`, a name with a space is
completed in its quoted form).
## Opting in
`[providers.<name>.models."<model>"].image_in` is the gate — an `@image` mention
is skipped, with a note naming the key, on a model that does not set it, and the
default catalogs mark what takes pictures (`deepseek-flash` yes,
`deepseek-v4-pro` no, OpenAI's defaults yes). A model missing from the catalog
has to be declared: the flag is an explicit opt-in.
## Not implemented
- **Files API** (`POST /files` → `file_id`): it lifts the 32 MiB per-image cap
and avoids re-uploading the same bytes, but it does not change the token bill
(the model still processes the picture in every request that carries it), so a
local 8 MiB cap plus resizing covers the common case.
- **Image output**: the API documents none.
+91
View File
@@ -0,0 +1,91 @@
# Prompt notifications
A turn stops while a prompt waits on the human. If you switched to another
window, the wait is invisible until you come back — so the CLI announces it.
A prompt is announced only after `grace_seconds` of waiting (5s by default), so
answering at the terminal stays silent. Bursts are throttled by
`min_interval_seconds`, and the announcement says what is being asked, not what
it would run: the one-line reason can name a command, argv or URL, and a
notification outlives the prompt (and may be mirrored to a lock screen), so it
travels only with `include_reason = true`.
## Configuration
```toml
[notify]
enabled = true
grace_seconds = 5.0
min_interval_seconds = 10.0
channels = ["auto"] # auto | desktop | terminal
actions = true # offer the answer, not just the news
include_reason = false
protocol = "" # terminal protocol override: osc777 / osc9 / bell
```
`channels = ["auto"]` tries the desktop first and falls back to the terminal;
`["desktop"]` and `["terminal"]` pin one of them. Notifications are only sent
from an interactive run — a one-shot `plyngent "…"` never waits on a human, and
a prompt answered inside the grace period is never announced at all.
## What is answered where
| Prompt | Notification offers |
|---|---|
| tool confirm (single / a round's batch) | Allow once / Deny / Go to terminal |
| command denylist, `fetch_local` private host | same three |
| directory-access request, skill write | same three |
| `ask_user_line` | a reply field — the answer *is* the text you type |
| `ask_user_choice`, `ask_user_form`, PTY secrets | nothing (terminal only) |
| limit-continue | nothing (terminal only) |
An answer read from the notification is treated exactly like a typed one —
`allow` is the `y` the prompt accepts, `deny` is `n` — and it never records a
session grant: the button means "this once". "Go to terminal" withdraws the
notification and leaves the prompt waiting. A secret is never asked for in a
notification, and never echoed into one.
## Desktop channel
The desktop channel needs the optional extra:
```console
pip install 'plyngent[notify]'
```
It wraps `desktop-notifier`, which talks to `org.freedesktop.Notifications` on
Linux, Notification Center on macOS and WinRT on Windows. Buttons need a
notification server that renders them (KDE Plasma and GNOME Shell do; a bare
`dunst` may not, and then the notification is text-only without any error). The
reply field is KDE-only in practice: it is the `inline-reply` capability plus the
`x-kde-reply-submit-button-text` hint, which only KNotifications implements.
Notifications are shown from the library's async API on a loop that runs in a
daemon thread, because callbacks only fire while the loop that owns the
notification is running.
## Terminal channel
No dependency and no extra: the CLI writes an escape sequence to `/dev/tty`
(never stdout, so a redirect cannot swallow it) and the terminal turns it into a
native notification.
| Protocol | Sequence | Terminals |
|---|---|---|
| OSC 777 | `ESC ] 777 ; notify ; title ; body` | Ghostty, WezTerm, Warp, Contour, VTE (GNOME Terminal, Tilix, Terminator), rxvt, Windows Terminal |
| OSC 9 | `ESC ] 9 ; title: body` | iTerm2, Apple Terminal, ConEmu |
| bell | `BEL` | everything else |
Detection reads the environment only (`TERM_PROGRAM`, `TERM`, `WT_SESSION`,
`VTE_VERSION`), so it never blocks and never touches the input stream;
`protocol` pins it. Inside tmux the sequence is wrapped in DCS passthrough —
tmux needs `set -g allow-passthrough on` — and inside GNU Screen in a Screen
DCS. kitty speaks OSC 99 only, which is not implemented, so it keeps the bell;
install the extra to reach it through the desktop instead.
## Failure is always silent
A missing extra, a desktop without a notification server, a refused write: each
falls through to the next channel and never raises into the prompt. There is no
sound, no remote push and no notification when a turn merely ends — only when a
prompt is waiting for you.
+4 -2
View File
@@ -48,8 +48,10 @@ Notes:
- Session-only helpers may add **`PUBLIC`** and **`SESSION_STATE`** when they
only touch session data and host-approved APIs (see plans under `doc/plans/`).
- Optional soft-confirm bits: **`YOLO`** (eligible for YOLO auto-approve),
**`TRUSTABLE`** (grant once per session after approve). Hard denylists are
never YOLO-skipped.
**`TRUSTABLE`** (grant once per session after approve). The host's danger
classifier may refuse that skip for one call by returning
`DangerReason(text, yolo_skip=False)` — plyngent does so for a command that
escalates privileges. Hard denylists are never YOLO-skipped.
- Optional host-policy bit: **`READ_ONLY`** marks tools that never mutate host
state — they become eligible for read-only contexts such as
`/btw --tools=read` side turns or planning. Tools that only sometimes mutate
+35 -2
View File
@@ -42,6 +42,35 @@
# include_project_roots = true
# allow_write = false
# Notifications while a prompt waits on the human: a tool confirmation, a
# directory-access grant, a skill write, an ``ask_user_*`` question, or the
# limit-continue prompt. A prompt is announced only after `grace_seconds` of
# waiting, so one you answer at the terminal stays silent; `min_interval_seconds`
# throttles bursts.
# channels: "auto" tries the desktop first (needs the `notify` extra:
# `pip install plyngent[notify]`) and falls back to a terminal escape sequence;
# "desktop" / "terminal" pin one of them.
# `actions` offers allow / deny / review as notification buttons; where the
# platform has none the notification stays text-only.
# `include_reason` adds the one-line reason — it can name a command or a URL, so
# it stays off by default (a notification may be mirrored to a lock screen).
# [notify]
# enabled = true
# grace_seconds = 5.0
# min_interval_seconds = 10.0
# channels = ["auto"]
# actions = true
# include_reason = false
# protocol = "" # terminal protocol: "" = detect, or osc777 / osc9 / bell
# Deletes. `delete_path` moves the path into the desktop's trash (freedesktop
# trash / Finder trash / Recycle Bin) so it can be restored from the file
# manager; `permanent=true` on the call removes it for good, and paths under the
# system temp dir are always deleted outright. `enabled = false` restores the
# old "a delete is final" behaviour for every call. See doc/trash.md.
# [trash]
# enabled = true
# MCP (Model Context Protocol) servers exposed as agent tools.
# Defining a server opts it in; tools appear as mcp__<server>__<tool> with
# LOCAL tags (confirm flow applies). Names in disable stay disconnected.
@@ -124,7 +153,9 @@ access_key_or_token = "sk-replace-me"
# timeout = { connect = 10, read = 600 }
[providers.openai_compat.models]
"gpt-4o-mini" = { text = true, cost_factor = 1.0 }
# ``image_in`` is the opt-in for a model that reads pictures (JPEG, PNG, GIF,
# WebP); an ``@image`` mention is skipped without it. See doc/images.md.
"gpt-4o-mini" = { text = true, image_in = true, cost_factor = 1.0 }
# --- OpenAI platform (Responses API; tag is "openai") ---
# If ``preset`` is omitted, it defaults to "openai".
@@ -202,5 +233,7 @@ access_key_or_token = "sk-replace-me"
# search_sources = ["deepseek"]
#
# [providers.deepseek.models]
# "deepseek-flash" = { text = true, convention = "responses" }
# Defaults: "deepseek-flash" takes pictures (image_in = true), "deepseek-v4-pro"
# is text-only.
# "deepseek-flash" = { text = true, image_in = true, convention = "responses" }
# "deepseek-v4-pro" = { text = true, reasoning_effort = "max" }
+100
View File
@@ -0,0 +1,100 @@
# Deletes and the system trash
`delete_path` is the agent's only way to remove a path, and it is tagged
`YOLO`: under `--yes` / `/yolo on` it runs with no confirmation at all. So the
default has to be recoverable — a delete moves the path into the desktop's own
trash, where the file manager the human already uses can put it back.
```text
deleted file notes.md (moved to system trash)
deleted directory build (moved to system trash)
deleted file notes.md # permanent=true
```
`permanent=true` is the old behaviour, chosen on purpose. `[trash] enabled =
false` makes every delete that way.
## Where a path goes
| Platform | Trash |
|---|---|
| Linux / BSD | The freedesktop trash specification: `$XDG_DATA_HOME/Trash` (`~/.local/share/Trash` by default), or a per-volume trash when the path is on another filesystem |
| macOS | The Finder's trash (`FSMoveObjectToTrashSync`; `NSFileManager` when pyobjc is installed) |
| Windows | The Recycle Bin (`IFileOperation` with pywin32, else `SHFileOperation`) |
The platform work is [send2trash](https://github.com/arsenetar/send2trash), a
required dependency because a missing safety net that fails silently is worse
than no safety net. It is pure Python over `ctypes` — nothing to compile, no
runtime dependencies.
Nothing is renamed away piecemeal: the whole path moves into the trash in one
`rename`, so a large directory costs nothing to delete (across filesystems,
where the volume has no trash of its own, the fallback is a move into the home
trash, which does copy). A path trashed twice keeps both copies (`dup.txt`,
then `dup 1.txt`).
## What is deleted outright
| Case | Why |
|---|---|
| `permanent=true` | The call said so |
| `[trash] enabled = false` | The host said so |
| A path under the OS temporary directory | Scratch space: nobody restores it, and a trash on the `/tmp` volume would only collect it. The same rule that `new_temporary_workspace` cleans up with |
| `new_temporary_workspace` cleanup at chat exit | Same reason |
| `copy_path` / `move_path` with `overwrite=true` | The destination is being replaced by an explicit call; the source content is the point of the operation |
## When the trash is unavailable
A trashing that fails is reported, never downgraded to a deletion — the human
was told the path is recoverable, so guessing would be worse than stopping:
```text
error: cannot move to trash: Permission denied (use permanent=true)
error: cannot move to trash: send2trash is not installed (use permanent=true)
```
The usual causes are a path on a filesystem with no `.Trash` directory that the
user cannot create one on (network mounts, some FUSE filesystems, other
people's volumes), a parent directory without write permission, and a broken
install. In every case the path is untouched and the error names the way
forward.
## Restoring
Restore from the desktop's own trash — the file manager's "Trash" / "Recycle
Bin", which is where a human would look anyway. plyngent does not offer a
`restore` tool: `send2trash` does not report where a path landed, and the
platform trash formats differ. A delete therefore reports that the path is in
the trash, not where.
## What the trash does not cover
- **A shell command.** `run_argv(["rm", "-rf", …])`, a script, or anything run
through a PTY reaches the filesystem directly; the command denylist and the
soft confirm of interpreter runs are the only gates there, and `rm` does not
use the trash.
- **Plugins and MCP servers.** They bring their own handlers.
- **Writes.** `write_file` with `overwrite=true` replaces a file's contents in
place — it never removes a path. Only `delete_path`, the `copy_path` /
`move_path` overwrite, and the temporary-workspace cleanup remove paths.
## Privacy
A trashed path is still on disk until the trash is emptied — that is the point
of it. `[agent].path_denylist` still runs first, so a denied path is never
trashed either, and a trashed file has left the workspace (it is no longer in a
`git status`, and no longer readable by a path tool).
## Platform limits worth knowing
- **Windows**: the shell deletes some things outright even when the call asked
for the Recycle Bin — empty folders (a folder only reaches the bin with
content), files above the bin's size limit for that drive, and paths on
network shares. That is Windows' behaviour, not a fallback here.
- **Linux**: a path on another volume needs a trash directory there
(`volume/.Trash/$UID` with the sticky bit, or a creatable `volume/.Trash-$UID`).
Without one the file is moved into the home trash instead, which crosses a
filesystem boundary and therefore copies.
- **macOS**: without pyobjc the library calls the older
`FSMoveObjectToTrashSync`; installing `send2trash[nativeLib]` switches it to
`NSFileManager`. Both trash; neither needs anything from plyngent.
Generated
+255 -53
View File
@@ -2,10 +2,10 @@
# It is not intended for manual editing.
[metadata]
groups = ["default", "dev"]
groups = ["default", "dev", "notify"]
strategy = ["inherit_metadata"]
lock_version = "4.5.1"
content_hash = "sha256:b69c90f70f1118ebb9080c3e0190e8b4d85dd4e5999466b617430399d0ab3125"
content_hash = "sha256:6b62075d8b95d83a7d5d6c99c073db457750af4c8a4e988adbef8799f6f4167e"
[[metadata.targets]]
requires_python = "~=3.14"
@@ -50,6 +50,20 @@ files = [
{file = "basedpyright-1.40.2.tar.gz", hash = "sha256:d4db36d095230379acc66c7e5fba7e4b50ca902f6e803d696782dd031cb8d7d7"},
]
[[package]]
name = "bidict"
version = "0.24.1"
requires_python = ">=3.11"
summary = "The bidirectional mapping library for Python."
groups = ["notify"]
dependencies = [
"typing-extensions>=4.0; python_full_version < \"3.12\"",
]
files = [
{file = "bidict-0.24.1-py3-none-any.whl", hash = "sha256:fd3eaa737917d8a14f4baa391670c433c4e3f6f5fd2cd99d4bf436437f432364"},
{file = "bidict-0.24.1.tar.gz", hash = "sha256:4dca6c17f0b01700e9f24359daa5ebabf7be022d99f4cb2a257b6af2a5076c88"},
]
[[package]]
name = "charset-normalizer"
version = "3.5.2"
@@ -169,6 +183,69 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "dbus-fast"
version = "5.2.0"
requires_python = ">=3.11"
summary = "A faster version of dbus-next"
groups = ["notify"]
marker = "sys_platform == \"linux\""
files = [
{file = "dbus_fast-5.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff55fddbc7567cb39f10b5d7e9bed1f2b19c88fc1d18c86fa67ca06becfe8fe7"},
{file = "dbus_fast-5.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a772708d25c11e980642781f603882e3dc51b5767be19075ffc5a484c4d3411"},
{file = "dbus_fast-5.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c66b094e96c221b877ccd6627bc3b9d808ac8317a8f6adc1cb2a0223e7d64e2"},
{file = "dbus_fast-5.2.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:79b842eb42f439fabd47db9deb7846d849933eb864fc53373d355c63f850eaa6"},
{file = "dbus_fast-5.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:788861134ac1794d44a03970fc817896b4bb35247353eeb13c363e238f7d4474"},
{file = "dbus_fast-5.2.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:352e4cc8dbc608e297a73784857a8f9841d3a221b10e4b0f6a1b4b5168456e51"},
{file = "dbus_fast-5.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fc04ca465f9d9847aa4273efe85da8fed82988004f1002b833df788f48fc0ecd"},
{file = "dbus_fast-5.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bcc1514888cbb82533777f3855e06135e5e8526ca6d7f75687b8b1fcf140ce33"},
{file = "dbus_fast-5.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa260884e2df72d584ffec2d5d2f90ea0d624db8326ff0bea33b59f8998a09f2"},
{file = "dbus_fast-5.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc5845602cd734e01bcee84fc2ff08642987d95d42edb434d048103905d3173f"},
{file = "dbus_fast-5.2.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:288111b8d920b5ab445c2d9e4f13cd8521fe5233efe191c4749dd8fd07c5beb9"},
{file = "dbus_fast-5.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d828828f879c0536981c1eaf2d4c6fa65fd30354cecb1e16a158a9cda36a827c"},
{file = "dbus_fast-5.2.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c4e7f48961e7c85540086458be0c5ca6ae6272e327c15907bd7d1e777ab2ace"},
{file = "dbus_fast-5.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a0d506adfcbd5451e23ec2b645437ccf419e9ed7ad1f6f82b622d2a292fd23e5"},
{file = "dbus_fast-5.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:90da44436de6f5773637216159b7f0c5b53aa357c54c38593b12ff3ed3a4e649"},
{file = "dbus_fast-5.2.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1aad5984b9724f438a2ccd5e3df15723aece0d248b309576744345a04eee948a"},
{file = "dbus_fast-5.2.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dbe4982d86e93fe285c695c0808601e7187f01501df77c2342d4132c11bcac17"},
{file = "dbus_fast-5.2.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d01ae4246b3b503b529be3f4ad3660d92687b5d0f085683d2ef48ec3247d5133"},
{file = "dbus_fast-5.2.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f4a47be94f369cca2308645345df8a0949e9139f9b0f6e64fbd11945924b13b7"},
{file = "dbus_fast-5.2.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:594f755fe172c76dd1a7f6558504244a0713da4ce07d9cf9abb5db80372a5d4f"},
{file = "dbus_fast-5.2.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c0ca312d8643f1f358f9fd96d2ccaf8dccd01d0c14c08c20e3f1686aa198231d"},
{file = "dbus_fast-5.2.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:679f2daef2b88d6129845013403b32d184ecf90805fce247340469bd5f495943"},
{file = "dbus_fast-5.2.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b8a6c22657740523f8d16e4d373925a408c7dc30dfd4935ea21939c042510c"},
{file = "dbus_fast-5.2.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22ac2df864dd0532f3d797f21118341e7520d6b36ac68a327eac6291624fb2b"},
{file = "dbus_fast-5.2.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d91ce3cd74b3b8a1518afca3ceb90ab7b280a53e9c50a257453b83e48c4b19c"},
{file = "dbus_fast-5.2.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:cc171f8b0728626eba19ac5893cbf1a5a813120e6fd0440168ba013f941abeb8"},
{file = "dbus_fast-5.2.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:5e8d93ca1b3d344c7ff5c4959e6d8ac2c6a0e794aef9d1606177647d537b7e99"},
{file = "dbus_fast-5.2.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e6f32672a446284b0d381349c91f6602356a4c017c1604fccbcc02347496be92"},
{file = "dbus_fast-5.2.0.tar.gz", hash = "sha256:a4a5dddc04b1ade5eb7650d791e2f6fb7c1334595593473914e78a2526ecddda"},
]
[[package]]
name = "desktop-notifier"
version = "6.2.0"
requires_python = ">=3.9"
summary = "Python library for cross-platform desktop notifications"
groups = ["notify"]
dependencies = [
"bidict",
"dbus-fast; sys_platform == \"linux\"",
"packaging",
"rubicon-objc>=0.5; sys_platform == \"darwin\"",
"typing-extensions",
"winrt-runtime>=3.1; sys_platform == \"win32\"",
"winrt-windows-applicationmodel-core>=3.1; sys_platform == \"win32\"",
"winrt-windows-data-xml-dom>=3.1; sys_platform == \"win32\"",
"winrt-windows-foundation-collections>=3.1; sys_platform == \"win32\"",
"winrt-windows-foundation>=3.1; sys_platform == \"win32\"",
"winrt-windows-ui-notifications>=3.1; sys_platform == \"win32\"",
]
files = [
{file = "desktop_notifier-6.2.0-py3-none-any.whl", hash = "sha256:193b0885e694a99ae22f72234c91afbac633074295b44183bf9716dd4077b8f3"},
{file = "desktop_notifier-6.2.0.tar.gz", hash = "sha256:528167b691ce40031fa92f67c9f452b7be29846613e19d11ec0c49cb5242d338"},
]
[[package]]
name = "greenlet"
version = "3.5.6"
@@ -414,7 +491,7 @@ name = "packaging"
version = "26.3"
requires_python = ">=3.9"
summary = "Core utilities for Python packages"
groups = ["dev"]
groups = ["dev", "notify"]
files = [
{file = "packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c"},
{file = "packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79"},
@@ -422,13 +499,13 @@ files = [
[[package]]
name = "platformdirs"
version = "4.13.3"
version = "4.13.5"
requires_python = ">=3.11"
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
groups = ["default"]
files = [
{file = "platformdirs-4.13.3-py3-none-any.whl", hash = "sha256:f6ad7f447f24f8a3b82cce5976387428bff894a0eca6c3488f4a17f153c130c4"},
{file = "platformdirs-4.13.3.tar.gz", hash = "sha256:5e567f664eb087ab8521c0179cd8d1bd60857d271136567a39719e28e2d383ce"},
{file = "platformdirs-4.13.5-py3-none-any.whl", hash = "sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e"},
{file = "platformdirs-4.13.5.tar.gz", hash = "sha256:05831adf87d392760f4b5c3a0a95579ab5a10b18b9b9bd43680dcb47544788a0"},
]
[[package]]
@@ -568,6 +645,18 @@ files = [
{file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"},
]
[[package]]
name = "rubicon-objc"
version = "0.5.7"
requires_python = ">=3.11"
summary = "A bridge between an Objective C runtime environment and Python."
groups = ["notify"]
marker = "sys_platform == \"darwin\""
files = [
{file = "rubicon_objc-0.5.7-py3-none-any.whl", hash = "sha256:3dc76bc29f300ee6b9a4f408748099f70f8472ca59fe70cca42dfa1f2f1bfe3f"},
{file = "rubicon_objc-0.5.7.tar.gz", hash = "sha256:ed2a56284a18ceb6370276069e8aab685b5465bbb457a5f13e09e37d0618703d"},
]
[[package]]
name = "ruff"
version = "0.16.10"
@@ -595,9 +684,20 @@ files = [
{file = "ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017"},
]
[[package]]
name = "send2trash"
version = "2.1.0"
requires_python = ">=3.8"
summary = "Send file to trash natively under Mac OS X, Windows and Linux"
groups = ["default"]
files = [
{file = "send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c"},
{file = "send2trash-2.1.0.tar.gz", hash = "sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459"},
]
[[package]]
name = "sqlalchemy"
version = "2.1.4"
version = "2.1.5"
requires_python = ">=3.11"
summary = "Database Abstraction Library"
groups = ["default"]
@@ -605,48 +705,48 @@ dependencies = [
"typing-extensions>=4.6.0",
]
files = [
{file = "sqlalchemy-2.1.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a2f6164c0527cd8fc4cea79a5c9d8369ffee417b8ba444a42342f36b91deb75"},
{file = "sqlalchemy-2.1.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6929a11ad26a91a4efd891c1252b373c2e88f056910b83ec6030ed3f2cbcb734"},
{file = "sqlalchemy-2.1.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14528d37d7d46a92f2a483f188f7fecd86cdd789254a0412b960c9fc5e9efd6d"},
{file = "sqlalchemy-2.1.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d2cb669c6bd1f19caf51db6e3c4fdd4cbb76f9db3ef81c3aeb5e288d9bae101b"},
{file = "sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:63dc25b21fd9a41dc09b7aada4b3b0d97cf4b6414f74bced6ac45326bc799ac9"},
{file = "sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:308f96d24e773d64609a2a0d1161a068f9f6e9165523bc4e07aa9c45f0c4213f"},
{file = "sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93b9416b9011a3b7689a933e04ac9f61d15686b6cb1948ebc1f41467153116c3"},
{file = "sqlalchemy-2.1.4-cp314-cp314-win32.whl", hash = "sha256:89db94855287fdac98d74595cf13ea59fbffa608d6400ff972b0fd4c036d873f"},
{file = "sqlalchemy-2.1.4-cp314-cp314-win_amd64.whl", hash = "sha256:080f8d853aac5bb5620f0ae6f46527397cf18dce0ec2b478b478469ef3cae2c4"},
{file = "sqlalchemy-2.1.4-cp314-cp314-win_arm64.whl", hash = "sha256:64d41be1dd88f184de1931f0173f4827122a1b49fd1150656641200c0bdf640c"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84272f329c15081a1e09b4a7261118b4e8a547f43e00fca98e55bbdf19eff3be"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b3f58bd26fc010ea28976d401845e4e6ce02e1b7c0288b3ea9c9a3c396f0bcc"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82d728075d42bd457d09655cf22e99d772a648c6f67e86743a4f05b7d063ca18"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0970394ec5d9e397aafc5bc5fa2b7f8b58cb191f2703006b19a96ef4bf00b8d9"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6005f2f5fcd67fdd721446128e6a2a1d18f77387a604fbd26b0006a086b33096"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0e01a3e199ae219381c4889993c5584b1b905fffe6830f639adb6770036a8913"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:22129e7d00ac66b291840c4dc83a9c497456ab5bffa682dcbfdc2356f9e49e5a"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-win32.whl", hash = "sha256:bc33d3e59d4e84b8866cc9ba13732585e37212dbe3542cb09f232682b36f47a5"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-win_amd64.whl", hash = "sha256:346d144e8912ae087b10d3c2081657cb634728600693eee6dbb71d7eb4768101"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3e5de57c71b3460e2ca6137e82cd3cb8c9f711f301f50d5c77156fdb9c822999"},
{file = "sqlalchemy-2.1.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:418786f05387ddb66ee683a1d016c5a8d9bf7be921e6ee8f285c7b6ac961a731"},
{file = "sqlalchemy-2.1.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:283914efed30e4d44301e36ac90ad048570538b8a70f072fe01578d9b205d09c"},
{file = "sqlalchemy-2.1.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d2eacdbeb990b80235763860923c60a8393745b66f7149a734980c65896da72"},
{file = "sqlalchemy-2.1.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e43fca5fdd5f34a3f8c54107a3648d3139de8bbf596a189f3f0de94bd84949bb"},
{file = "sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2e1b5343d315b10a4a71da481729f66f830a561595e02b61e8a5a65d658325ac"},
{file = "sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:42c37c06adcecf444e8c981f7e9237a41bdd445c83da0df9e08b4ad958becbbc"},
{file = "sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:bab7f51d38766d6a64da2b41976f1b3f9cc2ff37d3f2f63bdbac876199f3a48e"},
{file = "sqlalchemy-2.1.4-cp315-cp315-win32.whl", hash = "sha256:1541ba5bf0f232cd61f9ef3df78c93977c72ba6031506a0e6d057b2a3ddb76e9"},
{file = "sqlalchemy-2.1.4-cp315-cp315-win_amd64.whl", hash = "sha256:596a95611c217cb19c21f02f43c637cb507cab71dcf0467c5c7d98fcdd703007"},
{file = "sqlalchemy-2.1.4-cp315-cp315-win_arm64.whl", hash = "sha256:0d1ca95e42ce3c18818f170b741d30a33b292c6f6b9a202ffd717e28fc99b8c7"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0f672ed6972164fec94a8f0b21dcf8545080d0727866335fb8adf9f4764ce6ec"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72e3fa41d1fdab87d4e88bbdd69c9522e2795549fbe7b07bcf4ae9ec175f4b11"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cb2cb98d056e63e353ed697750004e07c79b054d73059ba3184ca3bb07296bea"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d66fdcc5506e0f8bb8d3f4f95125220a7cd6c46e8b1762750f01e9639973dd8"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:81f802c96dbf96e59c6982fa1b87da7868920fb0c27b9b81e560a62f57c2ccfb"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:acf8982c70471a68aa90d1aba08b48860c55b3357ec84ccb0f09368ead2ce099"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:778094c83e36c430756a7e1a1ac66fc3cffb2c6a1067958fe6b920abcec7bc5a"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-win32.whl", hash = "sha256:963348422b22f760e9462e56bc32bf4d95d224cc5b8c79a3c6e3b786d3d2a2b2"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-win_amd64.whl", hash = "sha256:fba3500e170d25f581e053009edeb0b158116084d91d465de218718d336b67c3"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-win_arm64.whl", hash = "sha256:0a9a464bc360856b7ea9bf8aa26aab92ca115dd08149cb0e004063d5db13584b"},
{file = "sqlalchemy-2.1.4-py3-none-any.whl", hash = "sha256:0b96edcc2cd60fe1e35f67a46f4eb076e57297841b9eae949ac5f196593f00a7"},
{file = "sqlalchemy-2.1.4.tar.gz", hash = "sha256:7bd7ad604487daa7eab8716471c29a7185f17b5287ce73bb7bc79fea050d8cfd"},
{file = "sqlalchemy-2.1.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5541a110ac4bb6377c785aea1b8f0b4d6658ed0ca5061ed1115d0725062b026b"},
{file = "sqlalchemy-2.1.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f029b2f7114623c03f762596f313515a2358004c44eb18924a15940f2e27d30f"},
{file = "sqlalchemy-2.1.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc44dcc80e89a006af415edffc7409b1f2976a4597065449c166987f95801ab4"},
{file = "sqlalchemy-2.1.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:72ddd7a8ec187d02de0ef316a15aaf381ddcc594958247b02a3dcdb4c42f0b02"},
{file = "sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f5173a6e60a15090f9e2348a8ca94cc13de72796c16733f3b62fbb22acba07b8"},
{file = "sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:09b10bd68223d57cce39c08947efae3f91ac40b552e07c7c869c3350a0fc4c23"},
{file = "sqlalchemy-2.1.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ea4cb5022462d9f15e7b72a52c267385aa97ac224c8503ac63ba90c0067ad0d9"},
{file = "sqlalchemy-2.1.5-cp314-cp314-win32.whl", hash = "sha256:c3ffbe6c680c626403b7e9697d92465501078731b9e8863b1410826e66b950e1"},
{file = "sqlalchemy-2.1.5-cp314-cp314-win_amd64.whl", hash = "sha256:061d089901319463a1160e4596191e355d2a9b4cce4852dac5adc301b085ac2f"},
{file = "sqlalchemy-2.1.5-cp314-cp314-win_arm64.whl", hash = "sha256:f5bc7fb93f1c17efefeb062bc4d2742ad1b71d56c36e6e2898a371ac6a56839a"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9bb993b1d0890a2da90589684c3d09de75df82c06aa8f7eda246c7cd0ea3e820"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df7e71ed051e4003e73484ef6547aacfb3a2322aac0c230d530c97e48e77b4d7"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c76d4cb9c76a6906563133449a76d9eae6c8529ca09484b8615ca604442b442e"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59a56be2e4e972061f9d5c1d5b01763b8b2207b48b956fbb6879c7443f5817f7"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc1a61d7f42e71746b5a2e10e90a6b174b4add441b7b6cf14278f1e029b2b800"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:740e96dfb99a615c8056070d3747efe8bce4c145e5953ccc9e4b004c139de0d2"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:92a0bb0595331c8e40bc615a67cc7bca9b761487409d1bf46c598b6ba5c0754d"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-win32.whl", hash = "sha256:11185c72b5372369166cb721537943f8e1e6e42a399e9bac19e02f3ee20592a4"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5173207b8fe1ddfc1d451c420a2a9cbbd0734b4a19896b8080e3744497e1c20c"},
{file = "sqlalchemy-2.1.5-cp314-cp314t-win_arm64.whl", hash = "sha256:def2f117a6ec0a25202ee36a148bbcfad3d43e2f2bb2e698e6d4448c6db5cb19"},
{file = "sqlalchemy-2.1.5-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:a0b80f91d9ed92be7a134278ea2e83a693c07d9986824ca51d859af9e57e18f5"},
{file = "sqlalchemy-2.1.5-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3033ddeeeb34c6c536622c07e932a20f8c9f58d6dd76e56595a76b55a25011ff"},
{file = "sqlalchemy-2.1.5-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4d30723171d511ec16ac057872a38b9b395420c0aaf81b2f94dd7bf1345c5fe"},
{file = "sqlalchemy-2.1.5-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cefa5badc13df1c9082bacdfa435c8eb6dcb9b32717f51d09d1e160a599646f"},
{file = "sqlalchemy-2.1.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2549e503185897de1fc5ca7730d5daed0d27d0b26df4a059dcdd33ebe81b7b4b"},
{file = "sqlalchemy-2.1.5-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:5a6871ee159a943d874012de835d56d0544ee871a99dbae05bc053da315b2c23"},
{file = "sqlalchemy-2.1.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:51e290080415325b66a94d20f6360e310606acb832f2bd677d4fcb95728e7ce1"},
{file = "sqlalchemy-2.1.5-cp315-cp315-win32.whl", hash = "sha256:c228084025a28aa038aef9b821d842508650fe12fe51f06e9cdaa4393be7812c"},
{file = "sqlalchemy-2.1.5-cp315-cp315-win_amd64.whl", hash = "sha256:bc09dd22c3fc491a2df7d16ca4167de35573f40c2e82dd4d6ddae589a73e7032"},
{file = "sqlalchemy-2.1.5-cp315-cp315-win_arm64.whl", hash = "sha256:375ef1bf78bc269a220963163d2580d5377898ed23a00bdd98a02d755421573d"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:7af1c92255cf8d50e87a808ce41096907332acdd09f285426a52f7c300e152db"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85891e0828e76a77fe912995dbc3ee949099488ba0a2ab323535d2e767446b20"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a72e062d2d38826dd89231df692fb40f12d7d1b9d118be23bcc1cf93c6e31782"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b79ee943e2e12246f873345762b83dbadd1fcfc3e832eab83272d672704dbdea"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:e9418d87b55c469c2f1328246d4533238cdc43134c695d6b9a6b7a87da49d463"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:898d86c22640fe52ffe02b4abc043a3c6754b4875837599e739be440966dffa1"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b3ad20cd035392f2b91a6466e24643990c1378beda80c9bda87a3a41e841e838"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-win32.whl", hash = "sha256:db34eefaf992c84460ab26ecf2fc2bdcc68965d6becfae8182cae0efeaa3e8f8"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-win_amd64.whl", hash = "sha256:173054351d863ddada188284e309e87a9f030132c8d534e05458971ff061aa1e"},
{file = "sqlalchemy-2.1.5-cp315-cp315t-win_arm64.whl", hash = "sha256:22c8d0873168a54f614e381559ea83d547b2cc5488b95b049bdc92667586bd5f"},
{file = "sqlalchemy-2.1.5-py3-none-any.whl", hash = "sha256:2f77b77b716f005b2dc3cc059d56df6a919bfb5b439e4149890a9df6c36be9e3"},
{file = "sqlalchemy-2.1.5.tar.gz", hash = "sha256:0df76cf1ca680ad745bd378a6299a058611a8a784eeaeb4ed78ad4a0b7ce2003"},
]
[[package]]
@@ -665,7 +765,7 @@ name = "typing-extensions"
version = "4.16.0"
requires_python = ">=3.9"
summary = "Backported and Experimental Type Hints for Python 3.9+"
groups = ["default"]
groups = ["default", "notify"]
files = [
{file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"},
{file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"},
@@ -673,7 +773,7 @@ files = [
[[package]]
name = "urllib3-future"
version = "2.25.902"
version = "2.25.903"
requires_python = ">=3.7"
summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces"
groups = ["default"]
@@ -683,8 +783,8 @@ dependencies = [
"qh3<3.0.0,>=1.5.4; (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\"",
]
files = [
{file = "urllib3_future-2.25.902-py3-none-any.whl", hash = "sha256:88e6f3c19f553931e78f84f011a34220f827c971e6cf06b3a075eaa788c2bf7d"},
{file = "urllib3_future-2.25.902.tar.gz", hash = "sha256:5c443668b9f87bce38bad1fdec7d7950c47e9c5b7328b1fd029a429741f3be70"},
{file = "urllib3_future-2.25.903-py3-none-any.whl", hash = "sha256:39352d455023402d86c4c1f567eba4f8b1bbf65571f8df96c2f5697c8058825f"},
{file = "urllib3_future-2.25.903.tar.gz", hash = "sha256:ef0011def1c6724f418d7970200ab8ccaf7d034ece152717b368afe512fb6d53"},
]
[[package]]
@@ -698,3 +798,105 @@ files = [
{file = "wassima-2.1.4-py3-none-any.whl", hash = "sha256:26022272d4618aada0e26df2e79dbc0e034be8be9d6d5396512690ffc6a94e7b"},
{file = "wassima-2.1.4.tar.gz", hash = "sha256:21bb77253bb8032c05393172c4d2df395f423b7e704538f3b2407f50c032034d"},
]
[[package]]
name = "winrt-runtime"
version = "3.2.1"
requires_python = ">=3.9"
summary = "Python projection of Windows Runtime (WinRT) APIs"
groups = ["notify"]
marker = "sys_platform == \"win32\""
dependencies = [
"typing-extensions>=4.12.2",
]
files = [
{file = "winrt_runtime-3.2.1-cp314-cp314-win32.whl", hash = "sha256:9b6298375468ac2f6815d0c008a059fc16508c8f587e824c7936ed9216480dad"},
{file = "winrt_runtime-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:e36e587ab5fd681ee472cd9a5995743f75107a1a84d749c64f7e490bc86bc814"},
{file = "winrt_runtime-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:35d6241a2ebd5598e4788e69768b8890ee1eee401a819865767a1fbdd3e9a650"},
{file = "winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea"},
]
[[package]]
name = "winrt-windows-applicationmodel-core"
version = "3.2.1"
requires_python = ">=3.9"
summary = "Python projection of Windows Runtime (WinRT) APIs"
groups = ["notify"]
marker = "sys_platform == \"win32\""
dependencies = [
"winrt-runtime~=3.2.1.0",
]
files = [
{file = "winrt_windows_applicationmodel_core-3.2.1-cp314-cp314-win32.whl", hash = "sha256:2a940b018989ba7159ace4c93eab5f5bce303ac5325ab29eeb0effe1d4a486a7"},
{file = "winrt_windows_applicationmodel_core-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:52f2e1cd5fa0a5b0592ddc40b423255491a40a2d6e4cf6ae766dd3b4810557ec"},
{file = "winrt_windows_applicationmodel_core-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:a0c18662c835fbb5044a5c3ca021d8f2f3f290c6238543ed74112a18e3d7d878"},
{file = "winrt_windows_applicationmodel_core-3.2.1.tar.gz", hash = "sha256:7cd21a4a7e25b099703a607f17ce804b5166cdf6444437440590fafea990fb46"},
]
[[package]]
name = "winrt-windows-data-xml-dom"
version = "3.2.1"
requires_python = ">=3.9"
summary = "Python projection of Windows Runtime (WinRT) APIs"
groups = ["notify"]
marker = "sys_platform == \"win32\""
dependencies = [
"winrt-runtime~=3.2.1.0",
]
files = [
{file = "winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win32.whl", hash = "sha256:77258030a725a32359fd2a93855cafa0e3f3f38e67afd5dfe74a8a215cbb31c4"},
{file = "winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:1cf1b6f31fff4e4c0ae30f1643b169da72b3b053a2996010f2c3a1e26b5d4970"},
{file = "winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:6de83f853d889444e4897413c2fa8183f86aa602edd34abf36a4fdc69db1650e"},
{file = "winrt_windows_data_xml_dom-3.2.1.tar.gz", hash = "sha256:db81f5956bf63ce8c9d8bfb6d0794ab2849f26e62a6256b833af8de3d6451399"},
]
[[package]]
name = "winrt-windows-foundation"
version = "3.2.1"
requires_python = ">=3.9"
summary = "Python projection of Windows Runtime (WinRT) APIs"
groups = ["notify"]
marker = "sys_platform == \"win32\""
dependencies = [
"winrt-runtime~=3.2.1.0",
]
files = [
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win32.whl", hash = "sha256:35e973ab3c77c2a943e139302256c040e017fd6ff1a75911c102964603bba1da"},
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22a7ebcec0d262e60119cff728f32962a02df60471ded8b2735a655eccc0ef5"},
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be7fbae829b98a6a946db4fbaf356b11db1fbcbb5d4f37e7a73ac6b25de8b87"},
{file = "winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656"},
]
[[package]]
name = "winrt-windows-foundation-collections"
version = "3.2.1"
requires_python = ">=3.9"
summary = "Python projection of Windows Runtime (WinRT) APIs"
groups = ["notify"]
marker = "sys_platform == \"win32\""
dependencies = [
"winrt-runtime~=3.2.1.0",
]
files = [
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win32.whl", hash = "sha256:33188ed2d63e844c8adfbb82d1d3d461d64aaf78d225ce9c5930421b413c45ab"},
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d4cfece7e9c0ead2941e55a1da82f20d2b9c8003bb7a8853bb7f999b539f80a4"},
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3884146fea13727510458f6a14040b7632d5d90127028b9bfd503c6c655d0c01"},
{file = "winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5"},
]
[[package]]
name = "winrt-windows-ui-notifications"
version = "3.2.1"
requires_python = ">=3.9"
summary = "Python projection of Windows Runtime (WinRT) APIs"
groups = ["notify"]
marker = "sys_platform == \"win32\""
dependencies = [
"winrt-runtime~=3.2.1.0",
]
files = [
{file = "winrt_windows_ui_notifications-3.2.1-cp314-cp314-win32.whl", hash = "sha256:00a7579ca0870134c27def1418c259fb18efe5b88aa399de85b4712f1add1b55"},
{file = "winrt_windows_ui_notifications-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:943599c727abf710ae94644b1d521e11857bd568e080e894a8be11aa717e383a"},
{file = "winrt_windows_ui_notifications-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:2a496c7e796b537966df7b26753b20bc40c5c1fbe96b3b3051a1debc00b80f07"},
{file = "winrt_windows_ui_notifications-3.2.1.tar.gz", hash = "sha256:eb29bf1c3306f3ceabd71d092b5505d5fe73fbc0660347dcc63ebc4204d34d21"},
]
+7 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "plyngent"
version = "0.10.0"
version = "0.11.0"
description = "LLM chat and agent toolkit"
authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" },
@@ -16,12 +16,17 @@ dependencies = [
"click>=8.4.2",
"awaitlet>=0.0.1",
"pywinpty>=2.0.0; sys_platform == \"win32\"",
"rich>=13"
"rich>=13",
"send2trash>=2.1.0"
]
requires-python = ">=3.14,<4"
readme = "README.md"
license = { text = "MIT" }
[project.optional-dependencies]
# Desktop notifications while a prompt waits on the human (see `[notify]`).
notify = ["desktop-notifier>=6.2.0"]
[project.scripts]
plyngent = "plyngent.cli:main"
+2
View File
@@ -30,10 +30,12 @@ from .notices import skill_tools_notice as skill_tools_notice
from .notices import truncated_turn_notice as truncated_turn_notice
from .tools import BatchConfirmDecision as BatchConfirmDecision
from .tools import DangerClassifier as DangerClassifier
from .tools import DangerReason as DangerReason
from .tools import ToolBatchConfirmHook as ToolBatchConfirmHook
from .tools import ToolConfirmHook as ToolConfirmHook
from .tools import ToolConfirmRequest as ToolConfirmRequest
from .tools import ToolDefinition as ToolDefinition
from .tools import ToolOutput as ToolOutput
from .tools import ToolRegistry as ToolRegistry
from .tools import ToolTag as ToolTag
from .tools import schema_from_callable as schema_from_callable
+23 -3
View File
@@ -7,7 +7,10 @@ from msgspec import UNSET
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
ChatImagePart,
ChatTextPart,
ToolChatMessage,
UserChatMessage,
)
if TYPE_CHECKING:
@@ -24,6 +27,11 @@ DEFAULT_OLD_TOOL_RESULT_CHARS = 800
# Soft-compact: leave this many most-recent tool results at full size.
DEFAULT_RECENT_TOOL_RESULTS = 12
# What one image costs in chars: a provider bills it by its resized size, at most
# 1024 tokens, and this estimator works in chars at ~4 chars/token. The shrinkers
# below never drop an image, so they must at least count it.
IMAGE_CHARS = 4_096
# Backward-compat alias (older code/docs may still import this name).
DEFAULT_CONTEXT_MAX_CHARS = DEFAULT_CONTEXT_MAX_TOKENS * 4
@@ -50,12 +58,21 @@ def truncate_tool_result(
return truncate_generic(text, max_chars)
def _content_chars(content: str | list[ChatTextPart | ChatImagePart]) -> int:
if isinstance(content, str):
return len(content)
return sum(len(part.text) if isinstance(part, ChatTextPart) else IMAGE_CHARS for part in content)
def estimate_message_chars(message: AnyChatMessage) -> int:
"""Rough character estimate for soft context budgeting (not tokenizer-accurate)."""
total = 0
content = getattr(message, "content", None)
if isinstance(content, str):
total += len(content)
if isinstance(message, UserChatMessage):
total += _content_chars(message.content)
else:
content = getattr(message, "content", None)
if isinstance(content, str):
total += len(content)
if isinstance(message, AssistantChatMessage):
tool_calls = message.tool_calls
if tool_calls is not UNSET and tool_calls:
@@ -71,6 +88,9 @@ def estimate_message_chars(message: AnyChatMessage) -> int:
total += len(getattr(custom, "input", "") or "")
if isinstance(message, ToolChatMessage):
total += len(message.tool_call_id)
images = message.images
if images is not UNSET and images:
total += IMAGE_CHARS * len(images)
return total
+20 -7
View File
@@ -15,6 +15,7 @@ from plyngent.lmproto.openai_compatible.model import (
SystemChatMessage,
ToolChatMessage,
UserChatMessage,
content_to_text,
)
from .budget import (
@@ -27,6 +28,7 @@ from .directive_checkpoint import (
parse_checkpoint_bands,
)
from .events import UsageEvent
from .images import user_content
from .loop import DEFAULT_MAX_ROUNDS, DEFAULT_MAX_TRUNCATION_CONTINUES, run_chat_loop
from .notices import TRUNCATED_NOTICE_KIND, notice_of_kind
from .todo_nag import (
@@ -40,7 +42,7 @@ from .usage import TokenUsage
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
from plyngent.lmproto.openai_compatible.model import AnyAssistantToolCall, AnyChatMessage
from plyngent.lmproto.openai_compatible.model import AnyAssistantToolCall, AnyChatMessage, ChatImagePart
from plyngent.memory import MemoryStore
from .events import AgentEvent
@@ -162,12 +164,12 @@ def incomplete_turn_user_text(messages: Sequence[AnyChatMessage]) -> str | None:
return None
last = body[-1]
if isinstance(last, UserChatMessage):
return last.content
return content_to_text(last.content)
if isinstance(last, ToolChatMessage) or (truncated and isinstance(last, AssistantChatMessage)):
# Walk back to the user that opened this turn.
for msg in reversed(body):
if isinstance(msg, UserChatMessage):
return msg.content
return content_to_text(msg.content)
return None
return None
@@ -558,7 +560,9 @@ class ChatAgent:
raise RuntimeError(msg)
for i in range(len(self.messages) - 1, -1, -1):
msg = self.messages[i]
if isinstance(msg, UserChatMessage) and msg.content == text:
# ``incomplete_turn_user_text`` returns the text view, so compare that
# (a turn carrying images is a part array, not a string).
if isinstance(msg, UserChatMessage) and content_to_text(msg.content) == text:
return i
msg = "nothing to retry"
raise RuntimeError(msg)
@@ -661,11 +665,20 @@ class ChatAgent:
await self._persist_range(self._persist_from, len(self.messages))
await self._persist_context_usage()
async def run(self, user_text: str) -> AsyncIterator[AgentEvent]:
"""Append a user message (persist immediately), run the tool loop, yield events."""
async def run(
self,
user_text: str,
*,
images: Sequence[ChatImagePart] | None = None,
) -> AsyncIterator[AgentEvent]:
"""Append a user message (persist immediately), run the tool loop, yield events.
*images* ride along with the text as content parts, which is the shape
the model sees an attachment in (see ``agent.images``).
"""
_ = reset_lineno_tracker()
self._ensure_system_prompt()
user_msg = UserChatMessage(content=user_text)
user_msg = UserChatMessage(content=user_content(user_text, images))
self.messages.append(user_msg)
await self._persist(user_msg)
self._persist_from = len(self.messages)
+101
View File
@@ -0,0 +1,101 @@
"""Image parts for the model: data URLs, format sniffing, and the size policy.
A model-visible image is a :class:`ChatImagePart` holding a URL — an ``http(s)``
link or an inline ``data:`` URL. Providers sniff the real format from the bytes
(never from a file name), so this module sniffs too and builds the data URL from
what it found.
Inline bytes are capped at :data:`MAX_INLINE_IMAGE_BYTES`, well under the 32 MiB
a provider accepts: every stored message is re-sent on each round and lives in
the session's JSON message column, so a huge screenshot would be paid for over
and over. A larger image has to be resized first.
"""
from __future__ import annotations
import base64
from typing import TYPE_CHECKING
from plyngent.lmproto.openai_compatible.model import (
ChatImagePart,
ChatImageUrl,
ChatTextPart,
ImageDetail,
)
if TYPE_CHECKING:
from collections.abc import Sequence
from plyngent.lmproto.openai_compatible.model import ChatContentPart
# Inline payload ceiling for one image (the API allows 32 MiB; see the module docstring).
MAX_INLINE_IMAGE_BYTES = 8 * 1024 * 1024
# Formatted images the API accepts, by their leading bytes.
_IMAGE_SIGNATURES: tuple[tuple[bytes, str], ...] = (
(b"\x89PNG\r\n\x1a\n", "image/png"),
(b"\xff\xd8\xff", "image/jpeg"),
(b"GIF87a", "image/gif"),
(b"GIF89a", "image/gif"),
)
# RIFF....WEBP: four bytes of container, four of size, then the form type.
_WEBP_HEADER_LEN = 12
def sniff_image_media_type(data: bytes) -> str | None:
"""Media type of an image *data* by its content, or ``None`` if unsupported."""
for signature, media_type in _IMAGE_SIGNATURES:
if data.startswith(signature):
return media_type
# WebP: RIFF....WEBP
if len(data) >= _WEBP_HEADER_LEN and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp"
return None
def to_data_url(media_type: str, data: bytes) -> str:
"""Encode *data* as an inline ``data:`` URL of *media_type*."""
return f"data:{media_type};base64,{base64.b64encode(data).decode('ascii')}"
def split_data_url(url: str) -> tuple[str, str] | None:
"""``(media_type, base64_payload)`` of a ``data:`` URL, or ``None`` otherwise."""
if not url.startswith("data:"):
return None
header, _, payload = url.partition(",")
media_type = header[len("data:") :].split(";")[0].strip()
if not media_type or not payload:
return None
return media_type, payload
def image_part_from_bytes(data: bytes, *, detail: ImageDetail = "auto") -> ChatImagePart | str:
"""An image part for *data*; a string is the model-facing error instead."""
media_type = sniff_image_media_type(data)
if media_type is None:
return "error: not a JPEG, PNG, GIF, or WebP image"
if len(data) > MAX_INLINE_IMAGE_BYTES:
return (
f"error: image is {len(data)} bytes, over the {MAX_INLINE_IMAGE_BYTES}-byte inline limit; "
"resize it and try again"
)
return ChatImagePart(image_url=ChatImageUrl(url=to_data_url(media_type, data), detail=detail))
def image_part_from_url(url: str, *, detail: ImageDetail = "auto") -> ChatImagePart:
"""An image part for a public ``http(s)`` URL or an inline ``data:`` URL."""
return ChatImagePart(image_url=ChatImageUrl(url=url, detail=detail))
def user_content(
text: str,
images: Sequence[ChatImagePart] | None = None,
) -> str | list[ChatContentPart]:
"""A user turn's content: the text alone, or a part array when images ride along."""
if not images:
return text
parts: list[ChatContentPart] = []
if text:
parts.append(ChatTextPart(text=text))
parts.extend(images)
return parts
+11 -3
View File
@@ -93,7 +93,7 @@ async def _run_one_tool(
) -> tuple[ToolChatMessage, ErrorEvent | None]:
if isinstance(call, AssistantFunctionToolCall):
try:
result_text = await tools.execute(call.function.name, call.function.arguments, decision=decision)
output = await tools.execute_output(call.function.name, call.function.arguments, decision=decision)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
@@ -101,8 +101,16 @@ async def _run_one_tool(
err = ErrorEvent(message=result_text, retryable=True, source="tool")
truncated = truncate_tool_result(result_text, max_result_chars)
return ToolChatMessage(content=truncated, tool_call_id=call.id), err
truncated = truncate_tool_result(result_text, max_result_chars)
return ToolChatMessage(content=truncated, tool_call_id=call.id), None
# Only the text is capped; an image the tool returned rides along whole.
truncated = truncate_tool_result(output.text, max_result_chars)
return (
ToolChatMessage(
content=truncated,
tool_call_id=call.id,
images=output.images or UNSET,
),
None,
)
msg = ToolChatMessage(
content="error: custom tool calls are not supported",
tool_call_id=call.id,
+53 -2
View File
@@ -13,8 +13,10 @@ from typing import TYPE_CHECKING, Any, cast
from msgspec import UNSET
from plyngent.agent.images import split_data_url
from plyngent.lmproto.anthropic.model import (
AnthropicAssistantMessage,
AnthropicImageContent,
AnthropicMessageResponse,
AnthropicMessagesParam,
AnthropicOutputConfig,
@@ -39,8 +41,11 @@ from plyngent.lmproto.openai_compatible.model import (
ChatCompletionChoice,
ChatCompletionResponse,
ChatCompletionsParam,
ChatImagePart,
ChatTextPart,
DeveloperChatMessage,
SystemChatMessage,
ToolChatMessage,
ToolFunctionItem,
UserChatMessage,
)
@@ -135,6 +140,52 @@ def _tool_choice_from_chat(param: ChatCompletionsParam) -> AnthropicToolChoice |
return UNSET
def _image_to_anthropic(part: ChatImagePart) -> AnthropicImageContent:
"""One chat image part as an Anthropic ``image`` block.
An inline ``data:`` URL becomes a ``base64`` source (the provider then reads
the media type we sniffed); anything else is a ``url`` source.
"""
inline = split_data_url(part.image_url.url)
if inline is None:
return AnthropicImageContent(source={"type": "url", "url": part.image_url.url})
media_type, payload = inline
return AnthropicImageContent(source={"type": "base64", "media_type": media_type, "data": payload})
type _AnthropicUserBlock = AnthropicTextContent | AnthropicImageContent | AnthropicToolResultContent
def _user_blocks_to_anthropic(
content: str | list[ChatTextPart | ChatImagePart],
) -> str | list[_AnthropicUserBlock]:
"""A chat user turn's content as Anthropic blocks (plain text stays a string)."""
if isinstance(content, str):
return content
return [
AnthropicTextContent(text=part.text) if isinstance(part, ChatTextPart) else _image_to_anthropic(part)
for part in content
]
def _tool_result_content(
message: ToolChatMessage,
) -> str | list[AnthropicTextContent | AnthropicImageContent]:
"""A tool result's content: its text, plus any images the tool returned.
Anthropic takes image blocks inside ``tool_result.content`` (a live probe
confirms the DeepSeek surface reads them), so the images ride in the result
itself instead of a separate user turn.
"""
if message.images is UNSET or not message.images:
return message.content
blocks: list[AnthropicTextContent | AnthropicImageContent] = []
if message.content:
blocks.append(AnthropicTextContent(text=message.content))
blocks.extend(_image_to_anthropic(part) for part in message.images)
return blocks
def _assistant_to_anthropic(message: AssistantChatMessage) -> AnthropicAssistantMessage | None:
"""The assistant turn for the wire: opaque blocks, then text, then calls.
@@ -211,7 +262,7 @@ def chat_messages_to_anthropic( # noqa: C901 — multi-role conversion
if isinstance(message, UserChatMessage):
flush_tool_results()
out.append(AnthropicUserMessage(content=message.content))
out.append(AnthropicUserMessage(content=_user_blocks_to_anthropic(message.content)))
continue
if isinstance(message, AssistantChatMessage):
@@ -225,7 +276,7 @@ def chat_messages_to_anthropic( # noqa: C901 — multi-role conversion
pending_tool_results.append(
AnthropicToolResultContent(
tool_use_id=message.tool_call_id,
content=message.content,
content=_tool_result_content(message),
)
)
+34 -2
View File
@@ -29,8 +29,11 @@ from plyngent.lmproto.openai_compatible.model import (
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
ChatImagePart,
ChatTextPart,
DeveloperChatMessage,
SystemChatMessage,
ToolChatMessage,
ToolFunctionItem,
UserChatMessage,
)
@@ -175,6 +178,35 @@ def _group_tool_outputs(
return out
def _image_to_input_part(part: ChatImagePart) -> dict[str, Any]:
"""One chat image part as a Responses ``input_image`` item."""
item: dict[str, Any] = {"type": "input_image", "image_url": part.image_url.url}
if part.image_url.detail is not UNSET:
item["detail"] = part.image_url.detail
return item
def _user_content_to_input(content: str | list[ChatTextPart | ChatImagePart]) -> str | list[dict[str, Any]]:
"""A chat user turn's content as ``input`` content (plain text stays a string)."""
if isinstance(content, str):
return content
return [
{"type": "input_text", "text": part.text} if isinstance(part, ChatTextPart) else _image_to_input_part(part)
for part in content
]
def _tool_output(message: ToolChatMessage) -> str | list[dict[str, Any]]:
"""A tool result's ``output``: its text, plus any images the tool returned."""
if message.images is UNSET or not message.images:
return message.content
parts: list[dict[str, Any]] = []
if message.content:
parts.append({"type": "input_text", "text": message.content})
parts.extend(_image_to_input_part(part) for part in message.images)
return parts
def chat_messages_to_responses_input(
messages: Sequence[AnyChatMessage],
) -> tuple[str | None, list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput]]:
@@ -191,7 +223,7 @@ def chat_messages_to_responses_input(
if message.content.strip():
items.append(ResponseEasyInputMessage(role="developer", content=message.content))
elif isinstance(message, UserChatMessage):
items.append(ResponseEasyInputMessage(role="user", content=message.content))
items.append(ResponseEasyInputMessage(role="user", content=_user_content_to_input(message.content)))
elif isinstance(message, AssistantChatMessage):
items.extend(_assistant_to_input_items(message))
else:
@@ -199,7 +231,7 @@ def chat_messages_to_responses_input(
items.append(
ResponseFunctionToolCallOutput(
call_id=message.tool_call_id,
output=message.content,
output=_tool_output(message),
)
)
+87 -25
View File
@@ -11,12 +11,27 @@ from pathlib import Path
from typing import Any, cast, get_args, get_origin, get_type_hints, overload
import msgspec
from msgspec import Struct
from plyngent.lmproto.openai_compatible.model import ToolFunction, ToolFunctionItem
from plyngent.lmproto.openai_compatible.model import ChatImagePart, ToolFunction, ToolFunctionItem
from plyngent.typedef import JSONSchema # noqa: TC001
@dataclass(frozen=True, slots=True)
class DangerReason:
"""Why a call needs a soft confirm, and whether YOLO mode may drop that prompt.
``yolo_skip`` is false for a call that reaches rights the session does not
have (``sudo`` / ``doas`` / ``pkexec`` and friends): the human answers it
however YOLO mode is set.
"""
text: str
yolo_skip: bool = True
type ToolHandler = Callable[..., Any | Awaitable[Any]]
type DangerClassifier = Callable[[str, Mapping[str, object]], str | None]
type DangerClassifier = Callable[[str, Mapping[str, object]], str | DangerReason | None]
# Returns True to allow, False to deny, or a non-empty str as denial reason for the model.
type ToolConfirmResult = bool | str
type ToolConfirmHook = Callable[
@@ -25,6 +40,24 @@ type ToolConfirmHook = Callable[
]
class ToolOutput(Struct):
"""A tool result: the model-facing text, plus images the model should see.
Most tools return their text directly; one that produced an image (a
screenshot, a chart, a downloaded picture) returns this so the image rides
along with the text that explains it. ``execute`` hands the pair to the
loop, which keeps the text as the tool message's content and the images
beside it.
"""
text: str
images: list[ChatImagePart] = msgspec.field(default_factory=list)
@property
def has_images(self) -> bool:
return bool(self.images)
@dataclass(frozen=True, slots=True)
class ToolConfirmRequest:
"""One call of a batch that needs a soft confirm before it may run."""
@@ -294,6 +327,13 @@ def _denied_error(name: str, reason: str, comment: str | None) -> str:
return f"error: tool {name!r} denied by user confirm ({reason})"
def _as_danger_reason(raw: str | DangerReason | None) -> DangerReason | None:
"""Normalize a classifier answer; a bare string is a reason YOLO mode may drop."""
if raw is None:
return None
return raw if isinstance(raw, DangerReason) else DangerReason(text=raw)
def _write_path_key(value: str) -> str:
"""Comparable key for one path argument of a writing tool.
@@ -444,7 +484,7 @@ class ToolRegistry:
return f"error: tool {definition.name!r} requires session state (SESSION_STATE) but none is bound"
return None
async def _invoke(self, definition: ToolDefinition, args: dict[str, object]) -> str:
async def _invoke(self, definition: ToolDefinition, args: dict[str, object]) -> str | ToolOutput:
try:
result = definition.handler(**args)
if inspect.isawaitable(result):
@@ -455,7 +495,7 @@ class ToolRegistry:
return f"error: invalid tool arguments: {exc}"
except Exception as exc: # noqa: BLE001 — surface tool failures to the model
return f"error: tool {definition.name!r} failed: {exc}"
if isinstance(result, str):
if isinstance(result, str | ToolOutput):
return result
return msgspec.json.encode(result).decode()
@@ -540,7 +580,9 @@ class ToolRegistry:
Mirrors the front half of :meth:`execute`: a call whose arguments fail to
decode or miss a required argument is answered with an error there and
must not be put in front of the user now.
must not be put in front of the user now, and a call whose reason YOLO
mode covers runs unprompted, so it must not take a slot in the round's
aggregated prompt either.
"""
definition = self._tools.get(name)
if self._danger is None or definition is None:
@@ -548,8 +590,10 @@ class ToolRegistry:
args = _decode_tool_args(arguments_json)
if isinstance(args, str) or _missing_required_error(definition, args) is not None:
return None
reason = self._danger(name, args)
return None if reason is None else ToolConfirmRequest(name=name, args=args, reason=reason)
reason = _as_danger_reason(self._danger(name, args))
if reason is None or (self._yolo and (definition.tags & ToolTag.YOLO) and reason.yolo_skip):
return None
return ToolConfirmRequest(name=name, args=args, reason=reason.text)
def _batch_requests(
self,
@@ -614,29 +658,32 @@ class ToolRegistry:
tags: ToolTag,
decision: BatchConfirmDecision | None = None,
) -> str | None:
"""Soft-confirm gate driven by danger reason + tags + grants.
"""Soft-confirm gate driven by danger reason + tags + grants + YOLO.
Pipeline (soft gate only; hard denylists stay in tool handlers)::
no soft reason → run
YOLO mode, (tags & YOLO) and a reason it may drop → allow
batch decision for this call → apply it
(tags & TRUSTABLE) and grant exists → allow
else → on_confirm (decorator YOLO tag does not override
dangerous invocations — internal danger classification
always takes precedence)
else → on_confirm (a tool without the YOLO tag, or a reason YOLO
may not drop — a privilege escalation — still prompts
under YOLO mode)
"""
if self._danger is None:
return None
reason = self._danger(name, args)
reason = _as_danger_reason(self._danger(name, args))
if reason is None:
return None
if self._yolo and (tags & ToolTag.YOLO) and reason.yolo_skip:
return None
async with self._confirm_lock:
if decision is not None:
return await self._apply_batch_decision(name, reason, decision, tags=tags)
return await self._apply_batch_decision(name, reason.text, decision, tags=tags)
if self._grant_allows(name, tags=tags):
return None
return await self._prompt_soft_confirm(name, args, reason, tags=tags)
return await self._prompt_soft_confirm(name, args, reason.text, tags=tags)
async def execute(
self,
@@ -645,28 +692,42 @@ class ToolRegistry:
*,
decision: BatchConfirmDecision | None = None,
) -> str:
"""Run a tool by name; returns a string result (errors become error text).
"""Run a tool by name; returns its text (errors become error text).
*decision* is the host's answer for this call from
:meth:`prepare_batch_confirm`; ``None`` means the call was not part of an
aggregated prompt and confirms on its own.
An image the tool returned is dropped here — this is the text-only entry
point. :meth:`execute_output` returns it; the agent loop uses that one.
"""
return (await self.execute_output(name, arguments_json, decision=decision)).text
async def execute_output(
self,
name: str,
arguments_json: str,
*,
decision: BatchConfirmDecision | None = None,
) -> ToolOutput:
"""Run a tool by name; returns its text plus any images it produced.
Errors become error text. *decision* is the host's answer for this call
from :meth:`prepare_batch_confirm`; ``None`` means the call was not part
of an aggregated prompt and confirms on its own.
"""
definition = self._tools.get(name)
if definition is None:
return f"error: unknown tool {name!r}"
return ToolOutput(text=f"error: unknown tool {name!r}")
args = _decode_tool_args(arguments_json)
if isinstance(args, str):
return args
return ToolOutput(text=args)
# Argument shape/coverage problems are answered before any confirm prompt:
# the human should never be asked about a call that cannot run.
missing_error = _missing_required_error(definition, args)
if missing_error is not None:
return missing_error
return ToolOutput(text=missing_error)
async def _run() -> str:
async def _run() -> ToolOutput:
missing = self._check_state_tags(definition)
if missing is not None:
return missing
return ToolOutput(text=missing)
# Held before the confirm prompt, so a sibling call writing the same
# file waits for this one instead of overtaking its prompt.
async with AsyncExitStack() as held:
@@ -674,8 +735,9 @@ class ToolRegistry:
await held.enter_async_context(lock)
denied = await self._maybe_confirm(name, args, tags=definition.tags, decision=decision)
if denied is not None:
return denied
return await self._invoke(definition, args)
return ToolOutput(text=denied)
result = await self._invoke(definition, args)
return result if isinstance(result, ToolOutput) else ToolOutput(text=result)
# Bind host-provided state for tools that read contextvars (todo, workspace).
# Tag enforcement remains gated by ``auto_bind_state``; binding is always useful
+16 -4
View File
@@ -35,6 +35,7 @@ from plyngent.runtime import McpManager, ProviderNotSupportedError, create_clien
if TYPE_CHECKING:
from collections.abc import Mapping
from plyngent.config.models import NotifyConfig
from plyngent.config.store import ConfigStore
_DEFAULT_DB_FILENAME = "chat.db"
@@ -128,13 +129,22 @@ def _read_prompt_text(prompt: str | None, *, stdin_isatty: bool) -> str | None:
return text or None
def _setup_hooks(*, interactive: bool, auto_continue: bool) -> None:
def _setup_hooks(*, interactive: bool, auto_continue: bool, notify: NotifyConfig) -> None:
"""Install limit hooks (workspace policy is set on ReplState).
``auto_continue`` keeps the PTY/limit hook installed for one-shot runs so
``--auto-continue`` / ``[agent] auto_continue_limits`` can raise limits
without a prompt; otherwise non-interactive runs keep today's deny.
without a prompt; otherwise non-interactive runs keep today's deny. The
prompt-wait notifier only makes sense for a human at a terminal, so a
non-interactive run clears it whatever ``[notify]`` says.
"""
from plyngent.cli.notify import install_notifier
from plyngent.prompting import set_prompt_wait_hook
if interactive:
_ = install_notifier(notify)
else:
set_prompt_wait_hook(None)
if interactive or auto_continue:
install_cli_limit_hooks()
else:
@@ -248,7 +258,7 @@ async def _run_chat( # noqa: C901, PLR0915 — chat orchestration
_warn_recoverable_providers(store.recoverable_providers)
auto_effective = auto_continue or store.agent_config.auto_continue_limits
_setup_hooks(interactive=interactive, auto_continue=auto_effective)
_setup_hooks(interactive=interactive, auto_continue=auto_effective, notify=store.notify_config)
# --yes forces sticky YOLO; else derive from config.confirm_destructive.
from plyngent.cli.state import YoloMode
@@ -337,8 +347,10 @@ async def _run_chat( # noqa: C901, PLR0915 — chat orchestration
quiet=quiet,
mcp_manager=mcp_manager,
)
# Path denylist and policy confirm live on instance.workspace (no process bag).
# Path denylist, trash switch and policy confirm live on
# instance.workspace (no process bag).
state.instance_state.workspace.path_denylist = tuple(store.agent_config.path_denylist or ())
state.instance_state.workspace.trash_enabled = store.trash_config.enabled
from plyngent.tools.net import set_ssrf_assume_public_cidrs
# Clash Fake-IP etc.: treat configured CIDRs as public for fetch_local SSRF.
+16
View File
@@ -206,6 +206,19 @@ def _read_file_prefix(args_json: str) -> str:
return _pretty_prefix(f"* Read '{path}' ")
def _view_image_prefix(args_json: str) -> str:
"""``* View image 'path' ``: the picture is already on its way to the model."""
path = _json_str_arg(args_json, "path") or "?"
return _pretty_prefix(f"* View image '{path}' ")
def _view_image_detail(_args_json: str, result: str) -> str:
"""``view_image`` outcome: the picture was attached, or why it was not."""
if result.startswith("error:"):
return _pretty_segments((f"({result.partition('error: ')[2]})", "red"))
return _pretty_segments(("(attached)", "green"))
def _range_detail(result: str) -> str | None:
"""``L{begin}-{end} (done)`` detail of a ranged result, when it carries one."""
first = result.splitlines()[0] if result else ""
@@ -537,6 +550,8 @@ def _mutator_detail(name: str, result: str) -> str:
if match:
chars, _path = match.groups()
return _pretty_segments((f"({chars} chars)", None))
if name == "delete_path" and "system trash" in result:
return _pretty_segments(("(to trash)", "green"))
return _pretty_segments(("(done)", "green"))
@@ -896,6 +911,7 @@ def _skill_edit_detail(_args_json: str, result: str) -> str:
# Pretty tools: ``prefix`` prints as the call starts, ``detail`` when it lands.
_PRETTY_LINES: dict[str, PrettyLine] = {
"read_file": PrettyLine(prefix=_read_file_prefix, detail=_read_file_detail),
"view_image": PrettyLine(prefix=_view_image_prefix, detail=_view_image_detail),
"tree": PrettyLine(prefix=_tree_prefix, detail=_tree_detail),
"todo_list": _todo_line("todo_list"),
"todo_push": _todo_line("todo_push"),
+2 -1
View File
@@ -10,6 +10,7 @@ from plyngent.lmproto.openai_compatible.model import (
AssistantFunctionToolCall,
ToolChatMessage,
UserChatMessage,
content_to_text,
)
if TYPE_CHECKING:
@@ -78,7 +79,7 @@ def format_session_export_md(
def _format_message_md(message: AnyChatMessage) -> list[str]:
if isinstance(message, UserChatMessage):
return ["## user", "", message.content]
return ["## user", "", content_to_text(message.content)]
if isinstance(message, AssistantChatMessage):
parts: list[str] = ["## assistant", ""]
reasoning = message.reasoning_content
+149 -47
View File
@@ -4,6 +4,7 @@ import contextlib
import selectors
import shutil
import sys
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
@@ -13,6 +14,7 @@ from plyngent.cli.interrupt import off_loop_prompt, pause_task_cancel_for_prompt
from plyngent.prompting import (
ChoiceOption,
NonInteractiveError,
PromptAnswerSource,
ask,
ask_async,
choose,
@@ -20,6 +22,7 @@ from plyngent.prompting import (
configure_prompting,
confirm,
get_prompt_backend,
prompt_wait,
set_prompt_output_hook,
)
from plyngent.tools.access import AccessDecision
@@ -30,12 +33,37 @@ from plyngent.tools.workspace import AccessMode, parse_access_mode
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from pathlib import Path
type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"]
# Answers a notification offers (see `cli/notify.py`) mapped onto the keys the
# prompts below already accept, so one answer source fits every confirm.
_NOTIFY_ANSWERS = {"allow": "y", "deny": "n"}
_BOX_MIN_WIDTH = 40
_BOX_MAX_WIDTH = 100
_BOX_PAD = 2 # spaces inside left/right borders
# How long one poll slice waits before a pending notification answer is checked.
_ANSWER_POLL_SECONDS = 0.1
def _answer_source(wait: PromptAnswerSource | None) -> PromptAnswerSource | None:
"""The wait's answer source, but only when the prompt offered answers."""
return wait if wait is not None and wait.supports_actions else None
def _read_answer(
timeout_seconds: float | None,
wait: PromptAnswerSource | None,
) -> str | None:
"""One answer line, from stdin or the notification (``None`` on timeout)."""
raw = _read_yes_no_line_with_timeout(timeout_seconds, source=_answer_source(wait))
if raw is None:
return None
return _NOTIFY_ANSWERS.get(raw.strip().lower(), raw)
# Limit prompts accept ``yyy`` to stop asking for the rest of the turn.
_CONTINUE_PROMPT = "Raise limit and continue? [Y/n/yyy] (yyy = stop asking this turn): "
_CONTINUE_YES_TOKENS = frozenset({"", "y", "yes"})
@@ -174,8 +202,9 @@ def _decide_continue_limit(reason: str) -> bool:
backend = get_prompt_backend()
if not backend.is_interactive():
return False
_echo_continue_limit(reason)
token = backend.read_line(_CONTINUE_PROMPT, default="y").strip().lower()
with prompt_wait("limit", "limit reached — continue?", reason):
_echo_continue_limit(reason)
token = backend.read_line(_CONTINUE_PROMPT, default="y").strip().lower()
if token == _CONTINUE_AUTO_TOKEN:
_auto_continue.turn = True
return True
@@ -205,12 +234,33 @@ async def prompt_continue_limit_async(reason: str) -> bool:
return False
def _confirm_with(wait: PromptAnswerSource | None, prompt: str, *, default: bool) -> bool:
"""Yes/no, also taking an answer that arrived through a notification."""
source = _answer_source(wait)
if source is None:
return confirm(prompt, default=default)
suffix = "[Y/n]" if default else "[y/N]"
with contextlib.suppress(OSError):
_ = sys.stderr.write(f"{prompt} {suffix}: ")
_ = sys.stderr.flush()
try:
raw = _read_answer(None, wait)
except (KeyboardInterrupt, EOFError) as exc:
msg = "prompt cancelled"
raise NonInteractiveError(msg) from exc
token = (raw or "").strip().lower()
if not token:
return default
return token in {"y", "yes"}
def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool | str:
"""True allow; False deny; non-empty str = deny with comment for the model."""
del args
try:
_echo_tool_confirm(name, reason)
allowed = confirm("Allow this tool call?", default=False)
with prompt_wait("confirm", f"tool {name!r} needs confirmation", reason) as wait:
_echo_tool_confirm(name, reason)
allowed = _confirm_with(wait, "Allow this tool call?", default=False)
except NonInteractiveError:
return False
if allowed:
@@ -242,34 +292,64 @@ def _try_readline() -> str | None:
return None
def _read_yes_no_line_with_timeout(timeout_seconds: float) -> str | None:
"""Read one line from stdin with *timeout_seconds*; ``None`` on timeout/EOF.
Uses :mod:`selectors` on the process stdin FD. On Windows without selector
support, falls back to blocking readline (no true timeout).
"""
def _stdin_selector() -> selectors.BaseSelector | None:
"""A selector watching process stdin, or ``None`` when it cannot be polled."""
try:
if not sys.stdin.isatty():
return None
fd = sys.stdin.fileno()
except OSError, ValueError, AttributeError:
return _try_readline()
try:
selector = selectors.DefaultSelector()
_ = selector.register(fd, selectors.EVENT_READ)
except OSError, ValueError, AttributeError:
return None
return selector
def _read_yes_no_line_with_timeout(
timeout_seconds: float | None,
*,
source: PromptAnswerSource | None = None,
) -> str | None:
"""Read one line from stdin with *timeout_seconds*; ``None`` on timeout/EOF.
Polls the process stdin FD in short slices, so a *source* (a notification
answer) can end the read too; stdin wins whenever it becomes readable. A
``None`` timeout waits for a line or an answer, whichever comes first; with
neither stdin nor a *source* pollable the read gives up at once.
On Windows without selector support, falls back to blocking readline (no
true timeout, and no answers from a *source*).
"""
try:
if not sys.stdin.isatty():
return None if source is None else source.take()
except OSError, ValueError, AttributeError:
return _try_readline()
selector = _stdin_selector()
if selector is None:
return _try_readline()
deadline = None if timeout_seconds is None else time.monotonic() + timeout_seconds
result: str | None = None
try:
events = selector.select(timeout_seconds)
if not events:
return None
return sys.stdin.readline()
except OSError, ValueError, EOFError:
return None
while True:
if deadline is None:
remaining = _ANSWER_POLL_SECONDS
else:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
if selector.select(min(remaining, _ANSWER_POLL_SECONDS)):
try:
result = sys.stdin.readline()
except OSError, EOFError:
result = None
break
answer = None if source is None else source.take()
if answer is not None:
result = answer
break
finally:
selector.close()
return result
def prompt_policy_command_confirm(
@@ -290,7 +370,10 @@ def prompt_policy_command_confirm(
f"(timeout {timeout_seconds:g}s defaults to DENY; not skipped by YOLO)"
)
try:
with pause_task_cancel_for_prompt():
with (
pause_task_cancel_for_prompt(),
prompt_wait("policy", f"allow command {basename!r}?", reason) as wait,
):
backend = get_prompt_backend()
if not backend.is_interactive():
return False
@@ -301,7 +384,7 @@ def prompt_policy_command_confirm(
with contextlib.suppress(OSError):
_ = sys.stderr.write(prompt)
_ = sys.stderr.flush()
raw = _read_yes_no_line_with_timeout(timeout_seconds)
raw = _read_answer(timeout_seconds, wait)
if raw is None:
with contextlib.suppress(OSError, NonInteractiveError):
backend.secho(
@@ -334,7 +417,10 @@ def prompt_policy_fetch_confirm(
f"(timeout {timeout_seconds:g}s defaults to DENY; not skipped by YOLO)"
)
try:
with pause_task_cancel_for_prompt():
with (
pause_task_cancel_for_prompt(),
prompt_wait("policy", f"allow fetch_local {host!r}?", reason) as wait,
):
backend = get_prompt_backend()
if not backend.is_interactive():
return False
@@ -346,7 +432,7 @@ def prompt_policy_fetch_confirm(
with contextlib.suppress(OSError):
_ = sys.stderr.write(prompt)
_ = sys.stderr.flush()
raw = _read_yes_no_line_with_timeout(timeout_seconds)
raw = _read_answer(timeout_seconds, wait)
if raw is None:
with contextlib.suppress(OSError, NonInteractiveError):
backend.secho(
@@ -380,7 +466,10 @@ def prompt_skill_write_confirm(
f"(timeout {timeout_seconds:g}s defaults to DENY; not skipped by YOLO)",
]
try:
with pause_task_cancel_for_prompt():
with (
pause_task_cancel_for_prompt(),
prompt_wait("skills", f"allow {action} in {skill_dir.name!r}?", detail) as wait,
):
backend = get_prompt_backend()
if not backend.is_interactive():
return SkillWriteDecision(allow=False)
@@ -393,7 +482,7 @@ def prompt_skill_write_confirm(
with contextlib.suppress(OSError):
_ = sys.stderr.write(prompt)
_ = sys.stderr.flush()
raw = _read_yes_no_line_with_timeout(timeout_seconds)
raw = _read_answer(timeout_seconds, wait)
if raw is None:
with contextlib.suppress(OSError, NonInteractiveError):
backend.secho(f"[skills] timed out after {timeout_seconds:g}s — denied", fg="red", err=True)
@@ -428,7 +517,10 @@ def prompt_directory_access_confirm(
lines.append(f"why: {reason}")
lines.append(f"(timeout {timeout_seconds:g}s defaults to DENY)")
try:
with pause_task_cancel_for_prompt():
with (
pause_task_cancel_for_prompt(),
prompt_wait("access", f"allow {mode.name.lower()} access to {path}?", reason) as wait,
):
backend = get_prompt_backend()
if not backend.is_interactive():
return None
@@ -444,7 +536,7 @@ def prompt_directory_access_confirm(
with contextlib.suppress(OSError):
_ = sys.stderr.write(prompt)
_ = sys.stderr.flush()
raw = _read_yes_no_line_with_timeout(timeout_seconds)
raw = _read_answer(timeout_seconds, wait)
if raw is None:
with contextlib.suppress(OSError, NonInteractiveError):
backend.secho(
@@ -470,8 +562,9 @@ async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reaso
try:
# Box is printed inside the worker thread via confirm's backend.
def _run() -> bool:
_echo_tool_confirm(name, reason)
return confirm("Allow this tool call?", default=False)
with prompt_wait("confirm", f"tool {name!r} needs confirmation", reason) as wait:
_echo_tool_confirm(name, reason)
return _confirm_with(wait, "Allow this tool call?", default=False)
from plyngent.prompting import run_prompt_async
@@ -512,21 +605,30 @@ def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchC
if not backend.is_interactive():
# Non-interactive: deny, like the default of the single prompt.
return BatchConfirmDecision(allowed=False)
backend.echo()
backend.secho(format_batch_confirm_box(requests), fg="yellow")
backend.secho("m = review them one call at a time", fg="bright_black")
backend.echo()
try:
token = (
backend.read_line(
f"Allow these {len(requests)} tool calls? [y/N/m]:",
default="n",
)
.strip()
.lower()
)
except NonInteractiveError:
return BatchConfirmDecision(allowed=False)
names = ", ".join(request.name for request in requests)
with prompt_wait("batch", f"{len(requests)} tool calls need confirmation", names) as wait:
backend.echo()
backend.secho(format_batch_confirm_box(requests), fg="yellow")
backend.secho("m = review them one call at a time", fg="bright_black")
backend.echo()
source = _answer_source(wait)
try:
if source is None:
token = (
backend.read_line(
f"Allow these {len(requests)} tool calls? [y/N/m]:",
default="n",
)
.strip()
.lower()
)
else:
with contextlib.suppress(OSError):
_ = sys.stderr.write(f"Allow these {len(requests)} tool calls? [y/N]: ")
_ = sys.stderr.flush()
token = (_read_answer(None, wait) or "n").strip().lower()
except NonInteractiveError:
return BatchConfirmDecision(allowed=False)
if token in _BATCH_MANUAL_TOKENS:
return BatchConfirmDecision(allowed=False, each=True)
if token in _BATCH_YES_TOKENS:
+217
View File
@@ -0,0 +1,217 @@
"""``@path`` mentions in a REPL line: attach the file the line refers to.
A mention starts a whitespace-delimited token, so an e-mail address (`a@b`) is
not one. Both spellings work::
look at @shot.png and @src/plyngent/cli/repl.py
look at @"screens/my shot.png"
The path resolves against the working directory (``~`` expands) and is checked
against the path denylist, like every tool path. What is attached depends on
the file:
- an image (JPEG, PNG, GIF, WebP, sniffed from the bytes) becomes an image part,
so the model sees the picture itself. A model without
``[providers.*.models.*].image_in`` skips it with a note instead of letting
the provider answer 400.
- any other file is read as text and appended to the message under a
``--- @path ---`` header, capped like a tool result; the remainder stays
resumable through the ``truncate_token`` in the marker.
Images and text both keep the typed ``@path`` in the message, so the model can
tell which attachment is which.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
from plyngent.agent.images import image_part_from_bytes, sniff_image_media_type
from plyngent.tools.truncate_token import truncate_to_memory
if TYPE_CHECKING:
from plyngent.cli.state import ReplState
from plyngent.lmproto.openai_compatible.model import ChatImagePart
MENTION = "@"
# One attached text file is capped like a tool result, and a turn's attachments
# together cannot outgrow this (the message is re-sent on every round).
MAX_MENTION_FILE_CHARS = DEFAULT_TOOL_RESULT_MAX_CHARS
MAX_MENTION_TOTAL_CHARS = 64_000
MAX_PATH_COMPLETIONS = 100
@dataclass(frozen=True, slots=True)
class Mention:
"""One ``@path`` reference as it was typed."""
token: str
path: str
@dataclass(slots=True)
class MentionPayload:
"""One turn's text plus what its mentions attached."""
text: str
images: list[ChatImagePart] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
def parse_mentions(line: str) -> list[Mention]:
"""Every ``@path`` mention in *line*, in the order typed."""
mentions: list[Mention] = []
index = 0
length = len(line)
while index < length:
char = line[index]
if char != MENTION or (index > 0 and not line[index - 1].isspace()):
index += 1
continue
start = index
index += 1
if index < length and line[index] == '"':
end = line.find('"', index + 1)
if end == -1:
# An unterminated quote is still being typed: leave it alone.
break
path = line[index + 1 : end]
index = end + 1
else:
while index < length and not line[index].isspace():
index += 1
path = line[start + 1 : index]
if path and not path.startswith(MENTION):
mentions.append(Mention(token=line[start:index], path=path))
return mentions
def mention_token_for(path: str) -> str:
"""The ``@path`` spelling for *path* (quoted when it holds a space)."""
if any(char.isspace() for char in path):
return f'{MENTION}"{path}"'
return f"{MENTION}{path}"
def resolve_mentions(state: ReplState, line: str) -> MentionPayload:
"""Attach what *line*'s mentions point at; notes say what was skipped."""
payload = MentionPayload(text=line)
mentions = parse_mentions(line)
seen: set[str] = set()
for mention in mentions:
if mention.path in seen:
continue
seen.add(mention.path)
budget = MAX_MENTION_TOTAL_CHARS - (len(payload.text) - len(line))
note = _attach_mention(state, payload, mention, budget)
if note is not None:
payload.notes.append(note)
return payload
def _attach_mention( # noqa: PLR0911 — one early return per rejected file
state: ReplState,
payload: MentionPayload,
mention: Mention,
budget: int,
) -> str | None:
"""Attach one mention; a returned string is a note for the user."""
target = _resolve(state, mention.path)
if isinstance(target, str):
return target
if not target.exists():
return f"{mention.token}: file not found"
if not target.is_file():
return f"{mention.token}: not a file"
data = target.read_bytes()
if sniff_image_media_type(data) is not None:
return _attach_image(state, payload, mention, data)
if b"\x00" in data[:512]:
return f"{mention.token}: binary file skipped (only images and text are attached)"
if budget <= 0:
return f"{mention.token}: skipped, this turn's attachments are at the size cap"
return _attach_text(payload, mention, data, budget)
def _resolve(state: ReplState, path: str) -> Path | str:
"""The absolute path for a mention, or a note when it is denylisted."""
from plyngent.tools.context import bind_instance
from plyngent.tools.workspace import denylist_match
target = Path(path).expanduser()
if not target.is_absolute():
target = Path.cwd() / target
with bind_instance(state.instance_state):
denied = denylist_match(target)
if denied is not None:
return f"@{path}: matches the path denylist pattern {denied!r}"
return target
def _attach_image(
state: ReplState,
payload: MentionPayload,
mention: Mention,
data: bytes,
) -> str | None:
"""Attach an image part, or explain why this turn cannot carry it."""
from plyngent.config.routing import model_config_for
model_config = model_config_for(state.provider, state.model)
if model_config is None or not model_config.image_in:
return (
f"{mention.token}: skipped, model {state.model!r} does not accept image input "
f'(set image_in = true under [providers.{state.provider_name}.models."{state.model}"])'
)
part = image_part_from_bytes(data, detail="auto")
if isinstance(part, str):
return f"{mention.token}: {part.removeprefix('error: ')}"
payload.images.append(part)
return None
def _attach_text(payload: MentionPayload, mention: Mention, data: bytes, budget: int) -> str | None:
"""Append a text file to the message under its own header."""
text = data.decode("utf-8", errors="replace")
# ``truncate_to_memory`` embeds the resumable token and marker itself.
body, token = truncate_to_memory(text, min(MAX_MENTION_FILE_CHARS, budget))
header = f"--- {mention.token} ({len(text)} chars{', truncated' if token is not None else ''}) ---"
payload.text = f"{payload.text}\n\n{header}\n{body}"
return None
def complete_mention_paths(incomplete: str, *, cwd: Path | None = None) -> list[str]:
"""Path candidates for the path part of a mention (directories end in ``/``)."""
base = cwd if cwd is not None else Path.cwd()
prefix = incomplete.rpartition("/")[2]
raw_dir = incomplete[: len(incomplete) - len(prefix)]
directory = Path(raw_dir).expanduser() if raw_dir else Path()
if not directory.is_absolute():
directory = base / directory
try:
entries = sorted(os.scandir(directory), key=lambda entry: entry.name)
except OSError:
return []
completions: list[str] = []
for entry in entries:
if prefix and not entry.name.startswith(prefix):
continue
if not prefix and entry.name.startswith("."):
continue
suffix = "/" if entry.is_dir() else ""
completions.append(f"{raw_dir}{entry.name}{suffix}")
if len(completions) >= MAX_PATH_COMPLETIONS:
break
return completions
def mention_completions(token: str, *, cwd: Path | None = None) -> list[str]:
"""Complete an ``@path`` token (including its ``@``) for readline."""
if not token.startswith(MENTION):
return []
paths = complete_mention_paths(token[len(MENTION) :], cwd=cwd)
return [mention_token_for(path) for path in paths]
+501
View File
@@ -0,0 +1,501 @@
"""Notify the human when a prompt is waiting on them.
A prompt blocks the turn, so someone who switched to another window learns about
it only when they come back. The notifier announces a prompt once it has waited
``grace_seconds`` — answering at the terminal stays silent — and, where the
platform allows it, offers the answer itself: allow / deny / review buttons for
a confirmation, a reply field for a question.
Two channels. The desktop one is the optional ``desktop-notifier`` extra
(``pip install plyngent[notify]``); the terminal one writes an OSC escape
sequence to the controlling terminal, which every modern terminal turns into a
native notification without any dependency at all.
"""
from __future__ import annotations
import asyncio
import contextlib
import importlib.util
import os
import sys
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from queue import Empty, SimpleQueue
from typing import TYPE_CHECKING, Any, Protocol, Self, TypeVar, runtime_checkable
from plyngent.prompting import PromptWait, get_prompt_backend, set_prompt_wait_hook
if TYPE_CHECKING:
from collections.abc import Callable, Coroutine, Mapping
from types import TracebackType
from plyngent.config.models import NotifyConfig
ALLOW = "allow"
DENY = "deny"
REVIEW = "review"
#: Kinds whose notification carries answer buttons (``ask`` gets a reply field).
_ANSWER_KINDS = frozenset({"confirm", "batch", "policy", "access", "skills"})
_BUTTONS: tuple[tuple[str, str], ...] = (
(ALLOW, "Allow once"),
(DENY, "Deny"),
(REVIEW, "Go to terminal"),
)
_MAX_TITLE = 80
_MAX_MESSAGE = 200
def _clean(text: str, limit: int) -> str:
"""One line, no control characters, truncated to *limit* characters."""
flat = " ".join(str(text).split())
stripped = flat.replace("\x1b", "").replace("\x07", "")
return stripped if len(stripped) <= limit else stripped[: limit - 1] + "…"
# --------------------------------------------------------------------------- #
# Desktop channel (optional `desktop-notifier` extra)
# --------------------------------------------------------------------------- #
@dataclass(frozen=True, slots=True)
class DesktopRequest:
"""One notification to show on the desktop, with the answers it offers."""
title: str
message: str
buttons: tuple[tuple[str, str], ...] = ()
reply_field: bool = False
on_answer: Callable[[str], None] | None = None
on_reply: Callable[[str], None] | None = None
@runtime_checkable
class DesktopChannel(Protocol):
"""A desktop notification surface (the extra, or a test double)."""
def capabilities(self) -> frozenset[str]:
"""``buttons`` / ``reply`` when answers can be offered."""
...
def send(self, request: DesktopRequest) -> str | None:
"""Show *request*; the returned id dismisses it again."""
...
def clear(self, identifier: str | None) -> None:
"""Dismiss a notification (a no-op for an unknown id)."""
...
class DesktopNotifierChannel:
"""``desktop-notifier`` on its own loop in a daemon thread.
A notification's callbacks only fire while the event loop that owns it is
running, which the library's synchronous wrapper does not provide — the
async API therefore runs here on a dedicated loop, and the prompt thread
waits on the results.
"""
def __init__(self) -> None:
self._loop: asyncio.AbstractEventLoop = asyncio.new_event_loop()
self._started: threading.Event = threading.Event()
self._notifier: object = None
self._capabilities: frozenset[str] = frozenset()
self._thread: threading.Thread = threading.Thread(target=self._serve, name="plyngent-notify", daemon=True)
self._thread.start()
_ = self._started.wait(5.0)
def _serve(self) -> None:
asyncio.set_event_loop(self._loop)
try:
self._loop.run_until_complete(self._init())
except Exception: # noqa: BLE001 - absent extra, or a platform that refuses
self._notifier = None
self._capabilities = frozenset()
finally:
self._started.set()
self._loop.run_forever()
async def _init(self) -> None:
from desktop_notifier import (
Capability,
DesktopNotifier,
)
notifier = DesktopNotifier(app_name="plyngent")
capabilities = await notifier.get_capabilities()
names: set[str] = set()
if Capability.BUTTONS in capabilities:
names.add("buttons")
if Capability.REPLY_FIELD in capabilities:
names.add("reply")
self._notifier = notifier
self._capabilities = frozenset(names)
def _call(self, coro: Coroutine[Any, Any, _T]) -> _T:
"""Run *coro* on the channel's loop and wait for its result."""
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
return future.result(5.0)
def capabilities(self) -> frozenset[str]:
return self._capabilities
def send(self, request: DesktopRequest) -> str | None:
if self._notifier is None:
msg = "no desktop notifier"
raise RuntimeError(msg)
from desktop_notifier import Button, ReplyField
buttons = [
Button(title=label, on_pressed=_button_handler(request.on_answer, key)) for key, label in request.buttons
]
reply = ReplyField(title="Answer", on_replied=_reply_handler(request.on_reply)) if request.reply_field else None
send = getattr(self._notifier, "send") # noqa: B009 - typed at runtime
return str(
self._call(
send(
title=request.title,
message=request.message,
buttons=buttons,
reply_field=reply,
)
)
)
def clear(self, identifier: str | None) -> None:
if identifier is None or self._notifier is None:
return
with contextlib.suppress(Exception):
clear = getattr(self._notifier, "clear") # noqa: B009 - typed at runtime
_ = self._call(clear(identifier))
def _button_handler(callback: Callable[[str], None] | None, key: str) -> Callable[[], None]:
"""A button callback carrying the answer *key*."""
def _run() -> None:
if callback is not None:
callback(key)
return _run
def _reply_handler(callback: Callable[[str], None] | None) -> Callable[[str], None]:
"""A reply-field callback carrying the typed text."""
def _run(text: str) -> None:
if callback is not None:
callback(text)
return _run
def load_desktop_channel() -> DesktopChannel | None:
"""The desktop channel, or ``None`` when the extra is not installed."""
if importlib.util.find_spec("desktop_notifier") is None:
return None
channel = DesktopNotifierChannel()
return channel if channel.capabilities() else None
# --------------------------------------------------------------------------- #
# Terminal channel (no dependency)
# --------------------------------------------------------------------------- #
_OSC9 = "\x1b]9;{title}: {message}\x07"
_OSC777 = "\x1b]777;notify;{title};{message}\x07"
_BELL = "\a"
#: VTE (GNOME Terminal, Tilix, Terminator, …) speaks OSC 777 from this version on.
_VTE_OSC777 = 4600
def _vte_version(env: Mapping[str, str]) -> int:
try:
return int(env.get("VTE_VERSION", "0"))
except ValueError:
return 0
def detect_protocol(env: Mapping[str, str], override: str = "") -> str:
"""Pick the terminal escape protocol: ``osc777``, ``osc9`` or ``bell``.
Detection is environment-only — no capability query — so it never blocks and
never touches the input stream. kitty speaks OSC 99 only, which this module
does not implement, so it keeps the plain bell.
"""
forced = override.strip().lower()
if forced in {"osc777", "osc9", "bell"}:
return forced
program = env.get("TERM_PROGRAM", "")
if env.get("KITTY_WINDOW_ID"):
return "bell"
if program in {"iTerm.app", "Apple_Terminal"}:
return "osc9"
if program in {"WezTerm", "ghostty"} or env.get("WT_SESSION"):
return "osc777"
if _vte_version(env) >= _VTE_OSC777:
return "osc777"
term = env.get("TERM", "")
return "osc777" if any(hint in term for hint in ("wezterm", "ghostty", "rxvt")) else "bell"
def detect_multiplexer(env: Mapping[str, str]) -> str:
"""``tmux`` / ``screen`` when the sequence must be wrapped, else ``""``."""
if env.get("TMUX"):
return "tmux"
return "screen" if env.get("STY") else ""
def terminal_sequence(protocol: str, title: str, message: str, *, multiplexer: str = "") -> str:
"""The escape sequence for one notification, wrapped for a multiplexer."""
if protocol == "bell":
return _BELL
safe_title = _clean(title, _MAX_TITLE)
safe_message = _clean(message, _MAX_MESSAGE)
if protocol == "osc9":
seq = _OSC9.format(title=safe_title, message=safe_message)
else:
seq = _OSC777.format(title=safe_title, message=safe_message)
if multiplexer == "tmux":
# tmux needs `set -g allow-passthrough on` and an ESC-prefixed payload.
return "\x1bPtmux;\x1b" + seq.replace("\x1b", "\x1b\x1b") + "\x1b\\"
if multiplexer == "screen":
return "\x1bP" + seq + "\x1b\\"
return seq
def write_terminal(data: str) -> None:
"""Write *data* to the controlling terminal; never raises.
``/dev/tty`` rather than stdout/stderr so a redirect cannot swallow the
notification or mix it into the program's output.
"""
payload = data.encode("utf-8", "replace")
if sys.platform != "win32":
try:
with Path("/dev/tty").open("wb") as tty:
_ = tty.write(payload)
tty.flush()
except OSError:
pass
else:
return
buffer = getattr(sys.stderr, "buffer", None)
if buffer is None:
return
with contextlib.suppress(OSError, ValueError):
_ = buffer.write(payload)
_ = sys.stderr.flush()
# --------------------------------------------------------------------------- #
# The notifier
# --------------------------------------------------------------------------- #
class _Resolve:
"""Sentinel: resolve the desktop channel lazily from the extra."""
_RESOLVE = _Resolve()
_T = TypeVar("_T")
class WaitHandle:
"""One waiting prompt: its grace timer, its answers, its notification."""
notifier: WaitNotifier
wait: PromptWait
identifier: str | None
#: Whether the notification offered answers, so a read may poll for them.
supports_actions: bool
def __init__(self, notifier: WaitNotifier, wait: PromptWait) -> None:
self.notifier = notifier
self.wait = wait
self.identifier = None
self.supports_actions = False
self._answers: SimpleQueue[str] = SimpleQueue()
self._timer: threading.Timer | None = None
self._closed: bool = False
def take(self) -> str | None:
"""The next answer offered by the notification, without blocking."""
try:
return self._answers.get_nowait()
except Empty:
return None
def answer(self, key: str) -> None:
"""Record one answer from the notification (a button, or a reply)."""
if self._closed:
return
if key == REVIEW and self.wait.kind != "ask":
# "review" means "I will answer in the terminal": withdraw and wait.
self.notifier.dismiss(self)
return
self._answers.put(key)
def __enter__(self) -> Self:
timer = threading.Timer(self.notifier.grace_seconds, self._notify)
timer.daemon = True
self._timer = timer
timer.start()
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
del exc_type, exc, tb
self._closed = True
if self._timer is not None:
self._timer.cancel()
self.notifier.dismiss(self)
def _notify(self) -> None:
if self._closed or not self.notifier.may_notify():
return
if not self.notifier.notify_desktop(self):
self.notifier.notify_terminal(self.wait)
class WaitNotifier:
"""Announce a waiting prompt once it has waited long enough.
Instances are the ``prompt_wait`` hook: calling one returns the
:class:`WaitHandle` context manager the prompt read consults.
"""
_config: NotifyConfig
_resolve_desktop: bool
_desktop: DesktopChannel | None
_write: Callable[[str], None]
_env: Mapping[str, str]
_clock: Callable[[], float]
_last_sent: float
def __init__(
self,
config: NotifyConfig,
*,
desktop: DesktopChannel | _Resolve | None = _RESOLVE,
write: Callable[[str], None] | None = None,
env: Mapping[str, str] | None = None,
clock: Callable[[], float] = time.monotonic,
) -> None:
self._config = config
if isinstance(desktop, _Resolve):
self._resolve_desktop = True
self._desktop = None
else:
self._resolve_desktop = False
self._desktop = desktop
self._write = write or write_terminal
self._env = os.environ if env is None else env
self._clock = clock
self._last_sent = float("-inf")
# -- hook ------------------------------------------------------------- #
def __call__(self, wait: PromptWait) -> WaitHandle:
return WaitHandle(self, wait)
@property
def grace_seconds(self) -> float:
return self._config.grace_seconds
def _wants(self, channel: str) -> bool:
channels = self._config.channels
return channel in channels or "auto" in channels
def may_notify(self) -> bool:
"""Whether a notification is worth sending right now."""
if not self._config.enabled or not get_prompt_backend().is_interactive():
return False
now = self._clock()
if now - self._last_sent < self._config.min_interval_seconds:
return False
self._last_sent = now
return True
def _message(self, wait: PromptWait) -> str:
title = _clean(wait.title, _MAX_TITLE)
if self._config.include_reason and wait.detail:
return f"{title}\n{_clean(wait.detail, _MAX_MESSAGE)}"
return title
def desktop_channel(self) -> DesktopChannel | None:
"""The desktop surface, resolved once (importing the extra on demand)."""
if self._resolve_desktop:
self._desktop = load_desktop_channel()
self._resolve_desktop = False
return self._desktop
def notify_desktop(self, handle: WaitHandle) -> bool:
"""Show the desktop notification; False when it could not be shown."""
if not self._wants("desktop"):
return False
channel = self.desktop_channel()
if channel is None:
return False
wait = handle.wait
capabilities = channel.capabilities()
buttons: tuple[tuple[str, str], ...] = ()
if self._config.actions and wait.kind in _ANSWER_KINDS and "buttons" in capabilities:
buttons = _BUTTONS
reply = self._config.actions and wait.kind == "ask" and "reply" in capabilities
try:
handle.identifier = channel.send(
DesktopRequest(
title=_clean(wait.title, _MAX_TITLE),
message=self._message(wait),
buttons=buttons,
reply_field=reply,
on_answer=handle.answer,
on_reply=handle.answer,
)
)
except Exception: # noqa: BLE001 - a notification never breaks a prompt
return False
handle.supports_actions = bool(buttons) or reply
return True
def notify_terminal(self, wait: PromptWait) -> None:
"""Write one terminal notification (silent on any failure)."""
if not self._wants("terminal"):
return
sequence = terminal_sequence(
detect_protocol(self._env, self._config.protocol),
wait.title,
self._message(wait),
multiplexer=detect_multiplexer(self._env),
)
self._write(sequence)
def dismiss(self, handle: WaitHandle) -> None:
"""Dismiss the handle's desktop notification, if one is showing."""
identifier = handle.identifier
if identifier is None:
return
handle.identifier = None
channel = self._desktop
if channel is not None:
channel.clear(identifier)
def install_notifier(config: NotifyConfig) -> WaitNotifier | None:
"""Install the prompt-wait hook from ``[notify]`` (None clears it)."""
if not config.enabled:
set_prompt_wait_hook(None)
return None
notifier = WaitNotifier(config)
set_prompt_wait_hook(notifier)
return notifier
+5 -1
View File
@@ -45,12 +45,16 @@ def build_completer(state: ReplState) -> Callable[[str, int], str | None]:
def completer(text: str, state_index: int) -> str | None:
import readline
from plyngent.cli.mentions import mention_completions
from plyngent.cli.slash import complete_slash_args
buffer = readline.get_line_buffer()
begidx = readline.get_begidx()
# An ``@path`` mention completes files wherever it sits in the line.
if text.startswith("@"):
options = mention_completions(text)
# Completing the first token (command).
if begidx == 0 or (begidx > 0 and buffer[:begidx].strip() == ""):
elif begidx == 0 or (begidx > 0 and buffer[:begidx].strip() == ""):
options = filter_prefix(text, slash_commands())
else:
head = buffer[:begidx].strip()
+15 -8
View File
@@ -34,6 +34,19 @@ def _echo_interrupted() -> None:
click.echo()
async def _send_turn(state: ReplState, text: str) -> None:
"""Send one user turn, attaching whatever its ``@path`` mentions point at."""
from plyngent.cli.mentions import resolve_mentions
payload = resolve_mentions(state, text)
for note in payload.notes:
click.secho(note, fg="yellow")
try:
_ = await run_user_text_with_retries(state.agent, payload.text, images=payload.images or None)
finally:
state.expire_yolo_once()
async def run_repl(state: ReplState) -> None:
"""Interactive chat loop with readline editing, history, and Tab completion."""
setup_readline(state)
@@ -79,16 +92,10 @@ async def run_repl(state: ReplState) -> None:
text = state.pending_user_text
state.pending_user_text = None
_echo_user(text)
try:
_ = await run_user_text_with_retries(state.agent, text)
finally:
state.expire_yolo_once()
await _send_turn(state, text)
continue
_echo_user(entry)
try:
_ = await run_user_text_with_retries(state.agent, entry)
finally:
state.expire_yolo_once()
await _send_turn(state, entry)
except KeyboardInterrupt:
_echo_interrupted()
+4 -2
View File
@@ -13,10 +13,11 @@ from plyngent.cli.interrupt import install_sigint_router, sigint_cancels
from plyngent.cli.limits import reset_auto_continue_turn
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable, Coroutine
from collections.abc import AsyncIterator, Callable, Coroutine, Sequence
from plyngent.agent import AgentEvent
from plyngent.agent.chat import ChatAgent
from plyngent.lmproto.openai_compatible.model import ChatImagePart
# Auto-retry budget after the first failure (10 attempts by default).
DEFAULT_MAX_AUTO_RETRIES = 10
@@ -238,10 +239,11 @@ async def run_user_text_with_retries(
agent: ChatAgent,
text: str,
*,
images: Sequence[ChatImagePart] | None = None,
delays: tuple[float, ...] = DEFAULT_RETRY_DELAYS_SECONDS,
) -> bool:
"""Send a new user message with auto-retry (empty ``delays`` = no auto-retry)."""
return await run_turn_with_retries(agent, starter=lambda: agent.run(text), delays=delays)
return await run_turn_with_retries(agent, starter=lambda: agent.run(text, images=images), delays=delays)
async def retry_pending_with_retries(agent: ChatAgent) -> bool:
+1
View File
@@ -655,6 +655,7 @@ class ReplState:
self.config.reload()
self.instance_state.workspace.path_denylist = tuple(self.config.agent_config.path_denylist or ())
self.instance_state.workspace.trash_enabled = self.config.trash_config.enabled
# Roots and their switches came from the file that was just re-read.
self._install_skills()
+5 -2
View File
@@ -28,6 +28,7 @@ from plyngent.lmproto.openai_compatible.model import (
SystemChatMessage,
ToolChatMessage,
UserChatMessage,
content_to_text,
)
if TYPE_CHECKING:
@@ -225,7 +226,7 @@ def build_transcript(messages: Sequence[AnyChatMessage], *, pending_retry_text:
if turns and pending_retry_text is not None:
user = turns[-1].user.message
if isinstance(user, UserChatMessage) and user.content == pending_retry_text:
if isinstance(user, UserChatMessage) and content_to_text(user.content) == pending_retry_text:
turns[-1] = replace(turns[-1], incomplete=True)
return Transcript(prelude=tuple(prelude), turns=tuple(turns))
@@ -239,7 +240,9 @@ def echo_row(row: Row, *, detail: Detail) -> None:
_echo_assistant(row, reasoning=detail == "full")
return
click.echo(row.prefix())
click.echo(row.message.content or "")
message = row.message
click.echo(content_to_text(message.content) if isinstance(message, UserChatMessage) else message.content)
click.echo()
click.echo()
+2
View File
@@ -17,6 +17,7 @@ from .models import McpConfig as McpConfig
from .models import McpServerConfig as McpServerConfig
from .models import ModelConfig as ModelConfig
from .models import NetworkingConfig as NetworkingConfig
from .models import NotifyConfig as NotifyConfig
from .models import OpenAICompatibleProvider as OpenAICompatibleProvider
from .models import OpenAIProvider as OpenAIProvider
from .models import PluginsConfig as PluginsConfig
@@ -24,6 +25,7 @@ from .models import ProviderConfig as ProviderConfig
from .models import ReasoningEffortConfig as ReasoningEffortConfig
from .models import SkillDiscoverySource as SkillDiscoverySource
from .models import SkillsConfig as SkillsConfig
from .models import TrashConfig as TrashConfig
from .models import compose_agent_system_content as compose_agent_system_content
from .path import get_default_path as _get_default_path
from .reasoning import ReasoningConfig as ReasoningConfig
+53 -5
View File
@@ -50,6 +50,8 @@ retrying that path.
fix `old_string` or `max_replaces`; shell workarounds rarely help.
- `write_file` refuses to replace an existing file (read it, or change it with \
`edit_replace` / `edit_lineno`); a full replacement needs `overwrite=true`, which asks the human.
- `delete_path` moves the path into the system trash, so a delete stays undoable \
(`permanent=true` deletes for good; scratch paths under the OS temp dir always do).
- `edit_replace` defaults to the first match; if remaining matches are reported, \
raise `max_replaces` or narrow `old_string`.
- `edit_lineno` edits only lines you read via `read_file(path, with_lineno=true)`; \
@@ -278,6 +280,47 @@ class SkillsConfig(Struct, omit_defaults=True):
allow_write: bool = False
class NotifyConfig(Struct, omit_defaults=True):
"""Desktop notifications shown while a prompt waits on the human.
A prompt is announced only after ``grace_seconds`` of waiting, so answering
at the terminal stays silent; ``min_interval_seconds`` throttles bursts.
``channels`` picks the surfaces: ``auto`` tries the desktop first and falls
back to the terminal, ``desktop`` / ``terminal`` pin one of them.
Desktop notifications come from the optional ``desktop-notifier`` extra
(``pip install plyngent[notify]``); without it the terminal channel is all
that is left. ``actions = false`` keeps the notification text-only, and
``include_reason`` lets the one-line reason through — it can name a command
or a URL, so it stays off by default (notifications outlive the prompt and
may be mirrored to a lock screen).
"""
enabled: bool = True
grace_seconds: float = 5.0
min_interval_seconds: float = 10.0
channels: list[str] = field(default_factory=lambda: ["auto"])
actions: bool = True
include_reason: bool = False
# Terminal protocol override: "" = detect, else osc777 / osc9 / bell.
protocol: str = ""
class TrashConfig(Struct, omit_defaults=True):
"""Whether a delete lands in the system trash or is final.
``enabled`` (the default) sends every path ``delete_path`` removes to the
desktop's own trash — the freedesktop trash on Linux, the Finder trash on
macOS, the Recycle Bin on Windows — so a deletion can be undone there.
``false`` restores the old behaviour of removing the path outright.
Scratch paths under the OS temporary directory, and a call that passes
``permanent=true``, are deleted for good either way.
"""
enabled: bool = True
class ModelConfig(Struct, omit_defaults=True):
"""Capability flags and optional routing overrides for a model.
@@ -344,9 +387,9 @@ class ProviderConfig(Struct, tag_field="preset", omit_defaults=True):
def _default_openai_models() -> dict[str, ModelConfig]:
"""Current OpenAI text catalog when TOML omits ``models``."""
return {
"gpt-5.4": ModelConfig(text=True),
"gpt-5.4-mini": ModelConfig(text=True),
"gpt-5.4-nano": ModelConfig(text=True),
"gpt-5.4": ModelConfig(text=True, image_in=True),
"gpt-5.4-mini": ModelConfig(text=True, image_in=True),
"gpt-5.4-nano": ModelConfig(text=True, image_in=True),
}
@@ -382,9 +425,14 @@ class AnthropicProvider(ProviderConfig, tag="anthropic"):
def _default_deepseek_models() -> dict[str, ModelConfig]:
"""Current DeepSeek text catalog when TOML omits ``models``."""
"""Current DeepSeek text catalog when TOML omits ``models``.
Only ``deepseek-flash`` reads pictures (JPEG, PNG, GIF, WebP in a user
message); ``deepseek-v4-pro`` stays text-only, so ``image_in`` is the flag
that decides whether a turn may carry one.
"""
return {
"deepseek-flash": ModelConfig(text=True),
"deepseek-flash": ModelConfig(text=True, image_in=True),
"deepseek-v4-pro": ModelConfig(text=True),
}
+55
View File
@@ -12,9 +12,11 @@ from .models import (
McpConfig,
ModelConfig,
NetworkingConfig,
NotifyConfig,
PluginsConfig,
Provider,
SkillsConfig,
TrashConfig,
)
if TYPE_CHECKING:
@@ -66,6 +68,38 @@ def _parse_skills(raw: dict[str, object]) -> SkillsConfig:
return SkillsConfig()
def _parse_notify(raw: dict[str, object]) -> NotifyConfig:
"""Parse the ``[notify]`` section, falling back to defaults.
Unknown keys are rejected like ``[mcp]`` does: a typo degrades to the
defaults (notifications on, ``auto`` channel) instead of half-applying.
"""
try:
config = msgspec.convert(raw, NotifyConfig)
except msgspec.ValidationError:
return NotifyConfig()
known = set(NotifyConfig.__struct_fields__)
if set(raw) - known:
return NotifyConfig()
return config
def _parse_trash(raw: dict[str, object]) -> TrashConfig:
"""Parse the ``[trash]`` section, falling back to defaults.
Unknown keys are rejected like ``[mcp]`` does: a typo leaves deletes going
to the trash instead of half-applying a policy about destructive ones.
"""
try:
config = msgspec.convert(raw, TrashConfig)
except msgspec.ValidationError:
return TrashConfig()
known = set(TrashConfig.__struct_fields__)
if set(raw) - known:
return TrashConfig()
return config
def _parse_mcp(raw: dict[str, object]) -> McpConfig:
"""Parse the ``[mcp]`` section, falling back to defaults.
@@ -160,6 +194,8 @@ class ConfigStore:
_networking: NetworkingConfig
_mcp: McpConfig
_skills: SkillsConfig
_notify: NotifyConfig
_trash: TrashConfig
_providers: dict[str, Provider]
_bad_providers: dict[str, object]
_recoverable_providers: dict[str, Provider]
@@ -174,6 +210,8 @@ class ConfigStore:
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {})))
self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {})))
self._skills = _parse_skills(cast("dict[str, object]", raw.get("skills", {})))
self._notify = _parse_notify(cast("dict[str, object]", raw.get("notify", {})))
self._trash = _parse_trash(cast("dict[str, object]", raw.get("trash", {})))
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document)
@property
@@ -238,6 +276,20 @@ class ConfigStore:
"""Typed skills section (roots, discovery sources, catalog limits)."""
return self._skills
# -- notify (read-only) --
@property
def notify_config(self) -> NotifyConfig:
"""Typed notifications section (grace, channels, actions)."""
return self._notify
# -- trash (read-only) --
@property
def trash_config(self) -> TrashConfig:
"""Typed trash section (whether a delete can be undone)."""
return self._trash
def set_plugins_enable(self, names: Sequence[str]) -> PluginsConfig:
"""Replace the plugin enable list (in-memory; call :meth:`write` to persist)."""
cleaned = [name.strip() for name in names if name.strip()]
@@ -442,6 +494,9 @@ class ConfigStore:
self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {})))
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {})))
self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {})))
self._skills = _parse_skills(cast("dict[str, object]", raw.get("skills", {})))
self._notify = _parse_notify(cast("dict[str, object]", raw.get("notify", {})))
self._trash = _parse_trash(cast("dict[str, object]", raw.get("trash", {})))
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(self._document)
# -- internal sync helpers --
+6
View File
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, Literal, cast, overload
import msgspec
from plyngent.lmproto.openai_compatible.client import BaseOpenAIClient
from plyngent.lmproto.openai_compatible.compat import chat_messages_with_images
from plyngent.lmproto.openai_compatible.config import OpenAIConfig # noqa: TC001
from plyngent.lmproto.openai_compatible.model import ( # noqa: TC001
ChatCompletionChunk,
@@ -49,6 +50,11 @@ class OpenAIClient(BaseOpenAIClient):
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
param = msgspec.structs.replace(param, stream=stream)
# Lift images into content parts, like every other chat-completions wire;
# the OpenAI API takes the ``developer`` role as-is, so only that step.
images = chat_messages_with_images(param.messages)
if images != param.messages:
param = msgspec.structs.replace(param, messages=images)
data = self.encoder.encode(param)
if stream:
resp = await self.session.post(
+7 -2
View File
@@ -98,10 +98,15 @@ class ResponseFunctionToolCall(Struct, tag_field="type", tag="function_call"):
class ResponseFunctionToolCallOutput(Struct, tag_field="type", tag="function_call_output"):
"""Tool result item passed back in subsequent ``input``."""
"""Tool result item passed back in subsequent ``input``.
``output`` may be a part array instead of text: a tool that returns an image
puts an ``input_image`` part beside its ``input_text`` (the Responses guide
documents images there, and a live probe reads them back).
"""
call_id: str
output: str
output: str | list[dict[str, Any]]
id: str | Unset = UNSET
status: ResponseItemStatus | Unset = UNSET
@@ -7,12 +7,18 @@ from typing import TYPE_CHECKING, Any, cast
import msgspec
from msgspec import UNSET
from .model import AssistantChatMessage, DeveloperChatMessage, SystemChatMessage
from .model import (
AssistantChatMessage,
DeveloperChatMessage,
SystemChatMessage,
ToolChatMessage,
UserChatMessage,
)
if TYPE_CHECKING:
from collections.abc import Sequence
from .model import AnyChatMessage, ChatCompletionsParam
from .model import AnyChatMessage, ChatCompletionsParam, ChatImagePart
def developer_to_system_message(message: DeveloperChatMessage) -> SystemChatMessage:
@@ -38,6 +44,38 @@ def coerce_developer_messages_to_system(messages: Sequence[AnyChatMessage]) -> l
return out if changed else list(messages)
def chat_messages_with_images(messages: Sequence[AnyChatMessage]) -> list[AnyChatMessage]:
"""Return *messages* with tool-result images lifted into the chat wire.
A user turn already carries its images in ``content`` blocks, which chat
completions take as-is. A ``tool`` role message may only carry text, so a
tool result's images move into the user turn emitted right after the run of
consecutive tool results.
With no such images the sequence's items come back unchanged.
"""
out: list[AnyChatMessage] = []
pending: list[ChatImagePart] = []
changed = False
def flush_images() -> None:
if pending:
out.append(UserChatMessage(content=list(pending)))
pending.clear()
for message in messages:
if isinstance(message, ToolChatMessage) and message.images is not UNSET and message.images:
out.append(msgspec.structs.replace(message, images=UNSET))
pending.extend(message.images)
changed = True
continue
if pending and not isinstance(message, ToolChatMessage):
flush_images()
out.append(message)
flush_images()
return out if changed else list(messages)
def coerce_chat_messages(messages: Sequence[AnyChatMessage]) -> list[AnyChatMessage]:
"""Return *messages* as a chat-completions wire can take them.
@@ -46,18 +84,19 @@ def coerce_chat_messages(messages: Sequence[AnyChatMessage]) -> list[AnyChatMess
and ``AssistantChatMessage.provider_blocks`` is dropped — those blocks
belong to the surface that produced them (Anthropic ``thinking`` and
server-tool blocks; see ``agent.messages_bridge``) and no other provider
knows the key.
knows the key. Tool-result images are lifted last (see
:func:`chat_messages_with_images`).
"""
coerced = coerce_developer_messages_to_system(messages)
changed = False
out: list[AnyChatMessage] = []
stripped: list[AnyChatMessage] = []
for message in coerced:
if isinstance(message, AssistantChatMessage) and message.provider_blocks is not UNSET:
out.append(msgspec.structs.replace(message, provider_blocks=UNSET))
stripped.append(msgspec.structs.replace(message, provider_blocks=UNSET))
changed = True
else:
out.append(message)
return out if changed else coerced
stripped.append(message)
return chat_messages_with_images(stripped if changed else coerced)
def coerce_chat_completions_param(param: ChatCompletionsParam) -> ChatCompletionsParam:
@@ -19,6 +19,41 @@ type Verbosity = Literal["low", "medium", "high"]
type GrammarSyntax = Literal["lark", "regex"]
type ImageDetail = Literal["low", "high", "original", "auto"]
class ChatImageUrl(Struct, omit_defaults=True):
"""The ``image_url`` payload of one image part (the canonical chat shape)."""
url: str
detail: ImageDetail | Unset = UNSET
class ChatImagePart(Struct, tag_field="type", tag="image_url"):
"""One image a message carries: an ``http(s)`` URL or a ``data:`` URL.
This is the shape OpenAI-compatible chat completions take. The other two
surfaces lift it into their own form — ``compat.chat_messages_with_images``
for chat completions, ``agent.messages_bridge`` (Anthropic ``image``
blocks) and ``agent.responses_bridge`` (``input_image`` parts).
"""
image_url: ChatImageUrl
class ChatTextPart(Struct, tag_field="type", tag="text"):
"""A text block beside images, when a message's content is a part array."""
text: str
type ChatContentPart = ChatTextPart | ChatImagePart
type ChatContent = str | list[ChatContentPart]
# What a text view of a part array shows where an image sits.
IMAGE_MARKER = "[image]"
class ChatMessage(Struct):
content: str
@@ -28,6 +63,12 @@ class SystemChatMessage(ChatMessage, tag_field="role", tag="system"):
class UserChatMessage(ChatMessage, tag_field="role", tag="user"):
# A user turn may carry images beside its text; chat completions already take
# that shape as ``content`` blocks, and the other two surfaces translate it
# (``agent.messages_bridge``, ``agent.responses_bridge``). The wider type is
# deliberate: only a user turn takes images, so the base stays a string and
# prose-only callers read it through ``content_to_text``.
content: str | list[ChatContentPart] # pyright: ignore[reportIncompatibleVariableOverride]
name: str | Unset = UNSET
@@ -35,6 +76,17 @@ class DeveloperChatMessage(ChatMessage, tag_field="role", tag="developer"):
name: str | Unset = UNSET
def content_to_text(content: ChatContent) -> str:
"""The plain text of a message ``content``, with ``[image]`` for each image.
Callers that only need prose (summaries, transcripts, exports, comparisons)
must not print a part array's ``repr``.
"""
if isinstance(content, str):
return content
return " ".join(part.text if isinstance(part, ChatTextPart) else IMAGE_MARKER for part in content)
type NamedChatMessage = SystemChatMessage | UserChatMessage | DeveloperChatMessage
@@ -84,6 +136,11 @@ class AssistantChatMessage(Struct, tag_field="role", tag="assistant"):
class ToolChatMessage(ChatMessage, tag_field="role", tag="tool"):
tool_call_id: str
# Images the tool returned. Chat completions cannot put an image in a
# ``tool`` message, so on that wire they move into the user message that
# follows the tool run (``compat.chat_messages_with_images``); the Anthropic
# and Responses surfaces carry them in the result itself.
images: list[ChatImagePart] | Unset = UNSET
type AnyChatMessage = (
+183 -42
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import contextlib
import contextvars
import select
import sys
import threading
@@ -117,6 +118,78 @@ def _break_output() -> None:
hook()
@dataclass(frozen=True, slots=True)
class PromptWait:
"""A prompt that is about to block on the human.
``kind`` names the prompt family (``confirm``, ``ask``, ``secret``,
``choose``, ``policy``, ``access``, ``skills``, ``limit``) so the host can
tailor what it shows and which answers it offers; ``title`` is short and
safe to surface anywhere, while ``detail`` carries one optional line the
caller has already redacted.
"""
kind: str
title: str
detail: str = ""
@runtime_checkable
class PromptAnswerSource(Protocol):
"""Answers a waiting prompt may take from a host surface (a notification)."""
@property
def supports_actions(self) -> bool:
"""Whether :meth:`take` can answer this prompt at all."""
...
def take(self) -> str | None:
"""Take the next host answer without blocking (``allow`` / ``deny`` / reply text)."""
...
type PromptWaitHook = Callable[[PromptWait], AbstractContextManager[PromptAnswerSource | None]]
# Hook run *while* a prompt blocks on the human. It yields the answer source the
# read consults (``None`` = notify only), so a host can reach someone who has
# switched to another window; without a hook, prompting behaves as before.
_wait_hook: PromptWaitHook | None = None
# Depth of the prompt_wait blocks currently entered (see prompt_wait).
_wait_depth: contextvars.ContextVar[int] = contextvars.ContextVar("prompt_wait_depth", default=0)
def set_prompt_wait_hook(hook: PromptWaitHook | None) -> None:
"""Install the hook run while a prompt blocks (None clears it)."""
global _wait_hook # noqa: PLW0603
_wait_hook = hook
def get_prompt_wait_hook() -> PromptWaitHook | None:
"""The installed prompt-wait hook, if any."""
return _wait_hook
@contextlib.contextmanager
def prompt_wait(kind: str, title: str, detail: str = "") -> Generator[PromptAnswerSource | None]:
"""Announce a blocking prompt to the host; yields its answer source (or None).
Only the outermost prompt of a nested read is announced: a confirm that asks
a follow-up question while it is already on screen must not notify — and
interrupt — a second time.
"""
hook = _wait_hook
if hook is None or _wait_depth.get() > 0:
yield None
return
token = _wait_depth.set(_wait_depth.get() + 1)
try:
with hook(PromptWait(kind, title, detail)) as source:
yield source
finally:
_wait_depth.reset(token)
def _readline_input(prompt: str, *, completions: Sequence[str] | None = None) -> str:
"""``input()`` with optional Tab completion via readline when available."""
try:
@@ -157,45 +230,60 @@ type PollFn = Callable[
def _read_line_with_timeout_posix(
prompt: str,
timeout: float,
timeout: float | None,
*,
stream: TextIO,
poll: PollFn,
source: PromptAnswerSource | None = None,
) -> str | None:
"""Blocking line read on POSIX: poll stdin, then read one line on data.
Returns the line (without trailing newline), ``""`` for an empty Enter, or
``None`` when the timeout elapses before any line is complete (EOF too).
Raises :class:`PromptCancelledError` when :func:`prompt_cancelled` turns true
while waiting (the poll loop notices within one interval).
``None`` when the timeout elapses before any line is complete (EOF too); a
``None`` *timeout* waits until a line or a *source* answer arrives. Raises
:class:`PromptCancelledError` when :func:`prompt_cancelled` turns true while
waiting (the poll loop notices within one interval).
"""
_ = sys.stdout.write(prompt)
_ = sys.stdout.flush()
deadline = time.monotonic() + timeout
deadline = None if timeout is None else time.monotonic() + timeout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
return None
if deadline is None:
remaining = 0.1
else:
remaining = min(deadline - time.monotonic(), 0.1)
if remaining <= 0:
return None
if prompt_cancelled():
raise PromptCancelledError
ready, _, _ = poll([stream], [], [], min(remaining, 0.1))
if not ready:
continue
line = stream.readline()
if line == "":
return None # EOF
return line.rstrip("\n")
ready, _, _ = poll([stream], [], [], remaining)
if ready:
line = stream.readline()
if line == "":
return None # EOF
return line.rstrip("\n")
if source is not None:
answer = source.take()
if answer is not None:
_ = sys.stdout.write(f"{answer}\n")
_ = sys.stdout.flush()
return answer
def _read_line_with_timeout_windows(prompt: str, timeout: float) -> str | None:
def _read_line_with_timeout_windows(
prompt: str,
timeout: float | None,
*,
source: PromptAnswerSource | None = None,
) -> str | None:
"""Blocking line read on Windows via msvcrt char polling (best-effort)."""
import msvcrt
_ = sys.stdout.write(prompt)
_ = sys.stdout.flush()
deadline = time.monotonic() + timeout
deadline = None if timeout is None else time.monotonic() + timeout
buf: list[str] = []
while time.monotonic() < deadline:
while deadline is None or time.monotonic() < deadline:
if prompt_cancelled():
raise PromptCancelledError
if msvcrt.kbhit():
@@ -214,28 +302,47 @@ def _read_line_with_timeout_windows(prompt: str, timeout: float) -> str | None:
buf.append(ch)
_ = sys.stdout.write(ch)
_ = sys.stdout.flush()
else:
time.sleep(0.05)
continue
if source is not None:
answer = source.take()
if answer is not None:
_ = sys.stdout.write(f"{answer}\n")
_ = sys.stdout.flush()
return answer
time.sleep(0.05)
_ = sys.stdout.write("\n")
_ = sys.stdout.flush()
return None
def read_line_with_timeout(prompt: str, timeout: float) -> str | None:
"""Read one line, blocking at most ``timeout`` seconds.
def read_line_with_timeout(
prompt: str,
timeout: float | None,
*,
source: PromptAnswerSource | None = None,
) -> str | None:
"""Read one line, blocking at most ``timeout`` seconds (``None`` = no limit).
Returns the typed line (without trailing newline), ``""`` for an empty
Enter, or ``None`` when the timeout elapses first (EOF too). Ctrl+C
propagates as :class:`KeyboardInterrupt`; :func:`cancel_prompt_read` (the
SIGINT target of an off-loop prompt read) raises
:class:`PromptCancelledError` instead. POSIX polls stdin via ``select``;
Windows uses ``msvcrt`` char polling (best-effort).
Enter, or ``None`` when the timeout elapses first (EOF too). With a
*source*, its answers end the read as well — the answer is echoed on the
prompt line like a typed one. Ctrl+C propagates as
:class:`KeyboardInterrupt`; :func:`cancel_prompt_read` (the SIGINT target of
an off-loop prompt read) raises :class:`PromptCancelledError` instead.
POSIX polls stdin via ``select``; Windows uses ``msvcrt`` char polling
(best-effort).
"""
if timeout < 0:
if timeout is not None and timeout < 0:
timeout = 0.0
if sys.platform == "win32":
return _read_line_with_timeout_windows(prompt, timeout)
return _read_line_with_timeout_posix(prompt, timeout, stream=sys.stdin, poll=cast("PollFn", select.select))
return _read_line_with_timeout_windows(prompt, timeout, source=source)
return _read_line_with_timeout_posix(
prompt,
timeout,
stream=sys.stdin,
poll=cast("PollFn", select.select),
source=source,
)
class ClickPromptBackend:
@@ -345,11 +452,12 @@ def get_prompt_backend() -> PromptBackend:
def reset_prompting() -> None:
"""Restore the default backend; clear the pause/output hooks and cancel flag (tests)."""
global _backend, _pause_factory, _before_output # noqa: PLW0603
"""Restore the default backend; clear the pause/output/wait hooks and cancel flag (tests)."""
global _backend, _pause_factory, _before_output, _wait_hook # noqa: PLW0603
_backend = ClickPromptBackend()
_pause_factory = None
_before_output = None
_wait_hook = None
_prompt_cancel.clear()
@@ -401,6 +509,34 @@ def _show_choices(
backend.echo(" (or type a custom answer)")
def _read_with_source(
prompt: str,
source: PromptAnswerSource | None,
*,
default: str | None,
completions: Sequence[str] | None = None,
) -> str:
"""Read one answer line, also accepting one offered by *source*.
The backend's readline path is kept whenever nothing can answer remotely, so
line editing (and Tab completion) only go away when the human asked for a
notification that carries the answer.
"""
backend = get_prompt_backend()
if source is None:
return backend.read_line(prompt, default=default, completions=completions).strip()
try:
answer = source.take() if not sys.stdin.isatty() else read_line_with_timeout(f"{prompt}: ", None, source=source)
except OSError, ValueError, AttributeError:
answer = source.take()
if answer is None:
if default is not None:
return default
msg = "prompt cancelled"
raise NonInteractiveError(msg)
return answer.strip()
def ask(
prompt: str,
*,
@@ -415,8 +551,10 @@ def ask(
if not backend.is_interactive() and default is None:
msg = f"non-interactive: cannot prompt for {prompt!r}"
raise NonInteractiveError(msg)
backend.secho(prompt, fg="yellow")
return backend.read_line("Answer", default=default, completions=completions).strip()
with prompt_wait("ask", prompt) as wait:
backend.secho(prompt, fg="yellow")
source = wait if wait is not None and wait.supports_actions else None
return _read_with_source("Answer", source, default=default, completions=completions)
def ask_secret(prompt: str) -> str:
@@ -428,8 +566,9 @@ def ask_secret(prompt: str) -> str:
if not backend.is_interactive():
msg = f"non-interactive: cannot prompt for secret {prompt!r}"
raise NonInteractiveError(msg)
backend.secho(prompt, fg="yellow")
return backend.read_secret_line("Secret")
with prompt_wait("secret", prompt):
backend.secho(prompt, fg="yellow")
return backend.read_secret_line("Secret")
def choose(
@@ -461,11 +600,12 @@ def choose(
raise NonInteractiveError(msg)
while True:
raw = backend.read_line(
"Choice",
default=default_display,
completions=completions,
).strip()
with prompt_wait("choose", prompt):
raw = backend.read_line(
"Choice",
default=default_display,
completions=completions,
).strip()
if not raw:
if default is not None:
return default
@@ -489,7 +629,8 @@ def confirm(prompt: str, *, default: bool = False) -> bool:
if not backend.is_interactive():
return default
backend.echo()
return backend.confirm(prompt, default=default)
with prompt_wait("confirm", prompt):
return backend.confirm(prompt, default=default)
def form(
+27 -9
View File
@@ -8,6 +8,7 @@ the process: start, tool listing, tool calls, reconnect, shutdown.
from __future__ import annotations
import asyncio
import base64
import importlib.metadata
import json
import os
@@ -17,6 +18,10 @@ from collections import deque
from dataclasses import dataclass
from typing import TYPE_CHECKING, cast
from plyngent.agent.images import image_part_from_bytes
from plyngent.agent.tools import ToolOutput
from plyngent.lmproto.openai_compatible.model import ChatImagePart
if TYPE_CHECKING:
from typing import Any
@@ -84,9 +89,10 @@ class McpTool:
)
def _format_content(result: Json) -> str:
"""Flatten a ``tools/call`` result payload into model-facing text."""
def _format_content(result: Json) -> ToolOutput:
"""Flatten a ``tools/call`` result payload into text plus any images."""
parts: list[str] = []
images: list[ChatImagePart] = []
for item in _json_list(result.get("content")):
entry = _json_dict(item)
kind = str(entry.get("type", ""))
@@ -94,12 +100,24 @@ def _format_content(result: Json) -> str:
text = entry.get("text")
if isinstance(text, str):
parts.append(text)
elif kind == "image":
# MCP carries an image as base64 + mimeType; the model wants the
# picture itself, not a ``[image: image/png]`` placeholder.
data = entry.get("data")
mime = entry.get("mimeType", "?")
part = image_part_from_bytes(base64.b64decode(data)) if isinstance(data, str) and data else None
if isinstance(part, ChatImagePart):
images.append(part)
elif isinstance(part, str):
parts.append(f"[image: {mime}] {part.removeprefix('error: ')}")
else:
parts.append(f"[image: {mime}]")
elif kind == "resource":
parts.append(f"[resource] {json.dumps(entry, ensure_ascii=False)}")
else:
mime = entry.get("mimeType", "?")
parts.append(f"[{kind or 'unknown'}: {mime}]")
return "\n".join(parts)
return ToolOutput(text="\n".join(parts), images=images)
class McpServerConnection:
@@ -307,17 +325,17 @@ class McpServerConnection:
cursor = next_cursor
return tools
async def call_tool(self, name: str, arguments: Json) -> str:
"""Run ``tools/call`` and return the flattened text result.
async def call_tool(self, name: str, arguments: Json) -> ToolOutput:
"""Run ``tools/call`` and return its text plus any images it produced.
Tool-level failures (``isError``) come back as ``error: …`` text, not
exceptions: the protocol round-trip itself succeeded.
"""
result = await self._request("tools/call", {"name": name, "arguments": arguments})
text = _format_content(result)
output = _format_content(result)
if result.get("isError") is True:
return f"error: {text or 'tool reported failure without content'}"
return text
return ToolOutput(text=f"error: {output.text or 'tool reported failure without content'}")
return output
class McpManager:
@@ -393,7 +411,7 @@ class McpManager:
connection.tools = await connection.list_tools()
connection.tools_stale = False
async def call(self, server: str, tool: str, arguments: Json) -> str:
async def call(self, server: str, tool: str, arguments: Json) -> ToolOutput:
connection = self._connections.get(server)
if connection is None or not connection.connected:
msg = f"server {server!r} is not connected"
+2
View File
@@ -101,6 +101,7 @@ from .workspace import denylist_match as denylist_match
from .workspace import get_command_denylist as get_command_denylist
from .workspace import get_path_denylist as get_path_denylist
from .workspace import get_policy_confirm_timeout as get_policy_confirm_timeout
from .workspace import get_trash_enabled as get_trash_enabled
from .workspace import get_workspace_root as get_workspace_root
from .workspace import grant_policy_command as grant_policy_command
from .workspace import granted_access_mode as granted_access_mode
@@ -116,5 +117,6 @@ from .workspace import set_path_denylist as set_path_denylist
from .workspace import set_policy_confirm_hook as set_policy_confirm_hook
from .workspace import set_policy_confirm_timeout as set_policy_confirm_timeout
from .workspace import set_static_read_roots as set_static_read_roots
from .workspace import set_trash_enabled as set_trash_enabled
from .workspace import set_workspace_root as set_workspace_root
from .workspace import within_workspace_roots as within_workspace_roots
+39 -1
View File
@@ -16,7 +16,16 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
__all__ = ["CommandString", "UnwrappedCommand", "basename", "is_interpreter", "unwrap_command"]
__all__ = [
"ESCALATION_PROGRAMS",
"CommandString",
"UnwrappedCommand",
"basename",
"dash_c_code",
"escalators_in_chain",
"is_interpreter",
"unwrap_command",
]
# Wrappers that run the program named on their own command line.
_ENV_WRAPPERS: frozenset[str] = frozenset(
@@ -99,6 +108,10 @@ _INTERPRETERS: frozenset[str] = frozenset(
# Trailing version of an interpreter: ``python3.12`` / ``node20`` / ``php8.2``.
_VERSION_SUFFIX = re.compile(r"[0-9][0-9.]*$")
# Privilege escalation: these run with rights the session itself does not have,
# so a call that reaches one is never approved on the caller's behalf
# (``danger._command_reason`` keeps the confirm whatever YOLO mode says).
ESCALATION_PROGRAMS: frozenset[str] = frozenset({"doas", "pkexec", "run0", "su", "sudo", "systemd-run"})
# ``FOO=bar`` in front of a program (env/sudo style).
_ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=")
@@ -245,6 +258,31 @@ def is_interpreter(base: str) -> bool:
return base in _INTERPRETERS or _VERSION_SUFFIX.sub("", base) in _INTERPRETERS
def dash_c_code(command: Sequence[str]) -> tuple[int, str] | None:
"""Index and text of the ``-c`` code in an interpreter argv, if present.
Only interpreters hand ``-c`` a block of code; for anything else it is a
plain flag (``grep -c``, ``od -c``) with nothing to review.
"""
for index, part in enumerate(command[1:], start=1):
if part == "-c":
return index, command[index + 1] if index + 1 < len(command) else ""
# Combined short forms are uncommon; only exact -c is supported.
return None
def escalators_in_chain(argv: Sequence[str]) -> tuple[str, ...]:
"""Escalation programs *argv* runs: the program itself or a wrapper around it.
``sudo -u root python …`` and ``doas bash`` both count, whatever program they
finally run. Code an interpreter only carries as text is out of scope here —
this reads the command line, not what the text would do.
"""
scan = unwrap_command(argv)
names = (scan.base, *scan.wrappers)
return tuple(dict.fromkeys(name for name in names if name in ESCALATION_PROGRAMS))
def unwrap_command(argv: Sequence[str]) -> UnwrappedCommand:
"""Resolve *argv* to the program behind any wrappers and ``run`` launchers.
+50 -26
View File
@@ -4,8 +4,10 @@ import json
import shlex
from typing import TYPE_CHECKING, cast
from plyngent.tools.command_scan import is_interpreter, unwrap_command
from plyngent.tools.workspace import AccessMode, WorkspaceError, resolve_path
from plyngent.agent.tools import DangerReason
from plyngent.tools.command_scan import dash_c_code, escalators_in_chain, is_interpreter, unwrap_command
from plyngent.tools.trash import under_system_temp
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_trash_enabled, resolve_path
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
@@ -23,19 +25,6 @@ def _as_argv(args: Mapping[str, object], *, key: str = "command") -> list[str] |
return out
def _dash_c_code(command: Sequence[str]) -> tuple[int, str] | None:
"""Index and text of the ``-c`` code in an interpreter argv, if present.
Only interpreters hand ``-c`` a block of code; for anything else it is a
plain flag (``grep -c``, ``od -c``) with nothing to review.
"""
for index, part in enumerate(command[1:], start=1):
if part == "-c":
return index, command[index + 1] if index + 1 < len(command) else ""
# Combined short forms are uncommon; only exact -c is supported.
return None
def _indent_block(text: str, *, prefix: str = " ") -> str:
"""Indent every line of *text* (for multi-line -c code in confirm prompts)."""
if not text:
@@ -51,7 +40,7 @@ def _format_argv_for_confirm(argv: Sequence[str], *, code_index: int | None) ->
return shlex.join(parts)
def _command_reason(argv: Sequence[str], *, via: str) -> str | None:
def _command_reason(argv: Sequence[str], *, via: str) -> DangerReason | None:
"""Confirm a command that runs code or detaches, whatever wraps it.
Multi-line reason (shown inside the CLI confirm box). ``via`` is a short
@@ -65,6 +54,11 @@ def _command_reason(argv: Sequence[str], *, via: str) -> str | None:
(``env -S "…"``, ``su -c "…"``) hides code the same way, so it is confirmed
too, with the text it carries printed under its own ``<wrapper> <option>:``
label.
A command that escalates privileges (``sudo``/``doas``/``pkexec`` as the
program or a wrapper) is confirmed with ``yolo_skip=False``. It never adds a
reason of its own: the denylist gate in the handler always asks about the
escalation, so a second prompt here would only repeat it.
"""
scan = unwrap_command(argv)
interpreter = is_interpreter(scan.base)
@@ -75,7 +69,10 @@ def _command_reason(argv: Sequence[str], *, via: str) -> str | None:
findings = list(dict.fromkeys(findings))
if not findings:
return None
code = _dash_c_code(scan.command) if interpreter else None
escalators = escalators_in_chain(argv)
if escalators:
findings.append(f"privilege escalation {', '.join(escalators)}")
code = dash_c_code(scan.command) if interpreter else None
code_index = scan.offset + code[0] if code is not None else None
display = _format_argv_for_confirm(argv, code_index=code_index)
reason = f"{via}: {' + '.join(findings)} — review before allow\n argv: {display}"
@@ -83,7 +80,7 @@ def _command_reason(argv: Sequence[str], *, via: str) -> str | None:
reason += f"\n command:\n{_indent_block(code[1])}"
for span in scan.command_strings:
reason += f"\n {span.label}:\n{_indent_block(span.text)}"
return reason
return DangerReason(text=reason, yolo_skip=not escalators)
def _write_file_reason(args: Mapping[str, object]) -> str | None:
@@ -136,10 +133,26 @@ def _delete_path_reason(args: Mapping[str, object]) -> str | None:
path = args.get("path")
recursive = bool(args.get("recursive", False))
extra = " recursively" if recursive else ""
return f"delete path {path!r}{extra}"
if _delete_is_final(args):
return f"delete path {path!r}{extra} permanently"
return f"move path {path!r}{extra} to the system trash"
def _run_argv_reason(args: Mapping[str, object]) -> str | None:
def _delete_is_final(args: Mapping[str, object]) -> bool:
"""True when the delete destroys the path instead of trashing it."""
if bool(args.get("permanent", False)):
return True
try:
if not get_trash_enabled():
return True
target = resolve_path(str(args.get("path", "")), required=AccessMode.WRITE)
except WorkspaceError:
# Path policy will refuse the call; word the confirm as the default.
return False
return under_system_temp(target)
def _run_argv_reason(args: Mapping[str, object]) -> DangerReason | None:
argv = _as_argv(args, key="argv")
if argv is None:
return None
@@ -161,8 +174,12 @@ def _batch_step_argv(item: object) -> list[str] | None:
return parts or None
def _run_argv_batch_reason(args: Mapping[str, object]) -> str | None:
"""One confirm for the whole batch if any step runs code or detaches."""
def _run_argv_batch_reason(args: Mapping[str, object]) -> DangerReason | None:
"""One confirm for the whole batch if any step runs code or detaches.
YOLO mode may drop it only when every step's reason allows that, so one
escalating step keeps the whole batch in front of the human.
"""
raw = args.get("steps")
if isinstance(raw, str):
try:
@@ -181,10 +198,13 @@ def _run_argv_batch_reason(args: Mapping[str, object]) -> str | None:
if not risky:
return None
header = f"run_argv_batch: {len(risky)} risky step(s) (review before allow)"
return header + "\n" + "\n".join(risky)
return DangerReason(
text=header + "\n" + "\n".join(reason.text for reason in risky),
yolo_skip=all(reason.yolo_skip for reason in risky),
)
def _open_pty_reason(args: Mapping[str, object]) -> str | None:
def _open_pty_reason(args: Mapping[str, object]) -> DangerReason | None:
argv = _as_argv(args)
if argv is None:
return None
@@ -204,14 +224,18 @@ def _fetch_reason(args: Mapping[str, object]) -> str | None:
return fetch_soft_reason(method, url, body)
def classify_danger(name: str, args: Mapping[str, object]) -> str | None: # noqa: PLR0911
"""Return a short reason if ``name``/``args`` need user confirm, else ``None``.
def classify_danger(name: str, args: Mapping[str, object]) -> str | DangerReason | None: # noqa: PLR0911
"""Return why ``name``/``args`` need a user confirm, else ``None``.
Hard denylists (paths/commands) still raise independently. This only covers
soft confirms for mutating tools and risky command launches — interpreters
(however invoked) and detached runners such as ``nohup``, resolved past
environment wrappers and ``<launcher> run``.
Private/loopback fetch_local targets use a separate policy grant (not YOLO).
A reason also says whether YOLO mode may drop its prompt
(:attr:`DangerReason.yolo_skip`); a command that escalates privileges
refuses.
"""
if name == "delete_path":
return _delete_path_reason(args)
+2
View File
@@ -11,10 +11,12 @@ from .listdir import listdir as listdir
from .read import read_file as read_file
from .regex_files import regex_files as regex_files
from .tree import tree as tree
from .view_image import view_image as view_image
from .write import write_file as write_file
FILE_TOOLS = [
read_file,
view_image,
write_file,
listdir,
tree,
+45 -23
View File
@@ -4,7 +4,14 @@ import shutil
from pathlib import Path
from plyngent.agent import ToolTag, invalidate_lineno_read, tool
from plyngent.tools.workspace import AccessMode, WorkspaceError, get_workspace_root, resolve_path
from plyngent.tools.trash import move_to_trash, under_system_temp
from plyngent.tools.workspace import (
AccessMode,
WorkspaceError,
get_trash_enabled,
get_workspace_root,
resolve_path,
)
def _kind(path: Path) -> str:
@@ -124,37 +131,47 @@ async def move_path(src: str, dst: str, *, overwrite: bool = False) -> str:
return _copy_or_move_validated(source, dest, root, src=src, dst=dst, overwrite=overwrite, action="move")
def _delete_directory(target: Path, path: str, *, recursive: bool) -> str:
if recursive:
shutil.rmtree(target)
def _discard(target: Path, path: str, *, kind: str, permanent: bool, detail: str = "") -> str:
"""Move *target* to the system trash (default), or delete it for good."""
if permanent:
_remove_existing(target)
invalidate_lineno_read(str(target))
return f"deleted directory {path} (recursive)"
try:
_ = next(target.iterdir())
except StopIteration:
target.rmdir()
invalidate_lineno_read(str(target))
return f"deleted empty directory {path}"
return f"error: directory not empty: {path} (set recursive=true)"
return f"deleted {kind} {path}{detail}"
error = move_to_trash(target)
if error is not None:
return error
invalidate_lineno_read(str(target))
return f"deleted {kind} {path} (moved to system trash)"
def _delete_target(target: Path, path: str, *, recursive: bool) -> str:
def _delete_target(target: Path, path: str, *, recursive: bool, permanent: bool) -> str:
if target.is_dir() and not target.is_symlink():
if recursive:
return _discard(target, path, kind="directory", permanent=permanent, detail=" (recursive)")
try:
_ = next(target.iterdir())
except StopIteration:
return _discard(target, path, kind="empty directory", permanent=permanent)
return f"error: directory not empty: {path} (set recursive=true)"
if target.is_symlink() or target.is_file():
kind = _kind(target)
target.unlink()
invalidate_lineno_read(str(target))
return f"deleted {kind} {path}"
if target.is_dir():
return _delete_directory(target, path, recursive=recursive)
return _discard(target, path, kind=_kind(target), permanent=permanent)
return f"error: unsupported path type: {path}"
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("path",))
async def delete_path(path: str, *, recursive: bool = False) -> str:
async def delete_path(path: str, *, recursive: bool = False, permanent: bool = False) -> str:
"""Delete a file or directory under the workspace.
Files and empty directories are removed always. Non-empty directories require
``recursive=true`` (uses ``shutil.rmtree``). The workspace root cannot be deleted.
The path goes to the system trash (the desktop's own trash / Recycle Bin), so
a deletion can be undone there; ``permanent=true`` removes it for good
instead. Files and empty directories are removed always, while non-empty
directories require ``recursive=true`` (uses ``shutil.rmtree``). The
workspace root cannot be deleted. When the trash is unavailable for this
path, nothing is deleted and the error says how to delete on purpose.
Paths under the OS temporary directory are deleted for good either way: they
are scratch space, not something to restore. A host with ``[trash] enabled =
false`` removes the path outright, as ``permanent=true`` does.
"""
try:
target = resolve_path(path, required=AccessMode.WRITE)
@@ -167,6 +184,11 @@ async def delete_path(path: str, *, recursive: bool = False) -> str:
if target == root:
return "error: cannot delete the workspace root"
try:
return _delete_target(target, path, recursive=recursive)
return _delete_target(
target,
path,
recursive=recursive,
permanent=permanent or not get_trash_enabled() or under_system_temp(target),
)
except OSError as exc:
return f"error: delete failed: {exc}"
+10 -1
View File
@@ -4,6 +4,7 @@ from typing import cast
from plyngent.agent import ToolTag, mark_lineno_read, tool
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
from plyngent.agent.images import sniff_image_media_type
from plyngent.tools.truncate_token import TruncateToken, truncate_with_token, truncation_marker
from plyngent.tools.workspace import AccessMode, resolve_path
@@ -39,9 +40,15 @@ def read_raw_text(path: str) -> tuple[str | None, str]:
Shared by ``read_file`` and ``get_truncated`` so truncate-token char offsets
always refer to the raw file text (never the ``L{begin}-{end}`` header).
Missing paths and directories are distinguished for the caller.
Missing paths and directories are distinguished for the caller, and a picture
is answered with the tool that shows it instead of decode noise.
"""
target = resolve_path(path, required=AccessMode.READ)
if target.is_file():
with target.open("rb") as handle:
media_type = sniff_image_media_type(handle.read(16))
if media_type is not None:
return None, f"error: {path} is an image ({media_type}); use view_image to look at it"
if not target.exists():
return None, f"error: file not found: {path}"
if not target.is_file():
@@ -71,6 +78,8 @@ async def read_file(
``DEFAULT_TOOL_RESULT_MAX_CHARS``); a ``truncate_token`` is appended when cut
so ``get_truncated`` can continue reading the rest without a new request.
Pass ``max_chars < 1`` to read unbounded.
An image file is refused with a pointer to ``view_image``, which shows it.
"""
target = resolve_path(path, required=AccessMode.READ)
text, err = read_raw_text(path)
+42
View File
@@ -0,0 +1,42 @@
"""Model-facing ``view_image``: hand a local picture to the model as a picture."""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from plyngent.agent import ToolOutput, ToolTag, tool
from plyngent.agent.images import image_part_from_bytes
from plyngent.tools.workspace import AccessMode, resolve_path
if TYPE_CHECKING:
from plyngent.lmproto.openai_compatible.model import ImageDetail
_DETAILS: frozenset[str] = frozenset({"low", "high", "original", "auto"})
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
async def view_image(path: str, detail: str = "auto") -> str | ToolOutput:
"""Look at an image file (JPEG, PNG, GIF, or WebP) under the workspace.
Use it for a screenshot, a chart, a diagram, or any other picture, instead
of ``read_file`` (which would return mojibake for those bytes).
``detail`` trades tokens for fine visual detail: ``low`` downscales to
512x512 before inference — fast and cheap when only the layout matters, but
small text stops being readable — while ``high``/``original`` keep the
picture as it is, and ``auto`` (the default) lets the provider decide.
The format is read from the file's own bytes, so a misnamed file still
works; anything else comes back as an error.
"""
if detail not in _DETAILS:
return f"error: detail must be one of {', '.join(sorted(_DETAILS))}"
target = resolve_path(path, required=AccessMode.READ)
if not target.exists():
return f"error: file not found: {path}"
if not target.is_file():
return f"error: not a file: {path}"
part = image_part_from_bytes(target.read_bytes(), detail=cast("ImageDetail", detail))
if isinstance(part, str):
return f"error: {path}: {part.removeprefix('error: ')}"
return ToolOutput(text=f"image: {path} (attached below)", images=[part])
+3 -3
View File
@@ -15,7 +15,7 @@ from __future__ import annotations
import re
from typing import TYPE_CHECKING
from plyngent.agent.tools import ToolDefinition, ToolTag
from plyngent.agent.tools import ToolDefinition, ToolOutput, ToolTag
from plyngent.tools.catalog import ToolSource, get_catalog
if TYPE_CHECKING:
@@ -43,8 +43,8 @@ def _make_handler(
manager: McpManager,
server: str,
tool: str,
) -> Callable[..., Awaitable[str]]:
async def handler(**arguments: object) -> str:
) -> Callable[..., Awaitable[ToolOutput]]:
async def handler(**arguments: object) -> ToolOutput:
return await manager.call(server, tool, dict(arguments))
return handler
+10 -1
View File
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any
import niquests
from plyngent.agent.images import sniff_image_media_type
from plyngent.tools.net.grants import ensure_host_allowed
from plyngent.tools.net.policy import (
DEFAULT_MAX_BYTES,
@@ -50,7 +51,10 @@ class FetchResult:
method: str
security: str
warnings: list[str] = field(default_factory=list)
body_kind: str = "text" # text | binary | empty
body_kind: str = "text" # text | binary | image | empty
# Raw bytes, kept only for an image body so the caller can hand the picture
# to the model instead of a hex preview.
body_image: bytes = b""
def _content_type(headers: Mapping[str, str] | object | None) -> str:
@@ -132,6 +136,10 @@ def _body_payload(data: bytes, content_type: str) -> tuple[str, str]:
return "empty", ""
if _looks_text(content_type, data):
return "text", _decode_body(data, content_type)
if sniff_image_media_type(data) is not None:
# An image is handed to the model as a picture, so the preview line only
# carries what the part cannot say itself.
return "image", f"(image body; content_type={content_type!r}; bytes={len(data)})"
preview = data[:64].hex()
return (
"binary",
@@ -279,6 +287,7 @@ async def http_fetch(
),
warnings=list(dict.fromkeys(warnings)),
body_kind=body_kind,
body_image=data if body_kind == "image" else b"",
)
+23 -4
View File
@@ -2,9 +2,10 @@
from __future__ import annotations
from plyngent.agent import ToolTag, tool
from plyngent.agent import ToolOutput, ToolTag, tool
from plyngent.agent.images import image_part_from_bytes
from plyngent.tools.context import is_read_only_context
from plyngent.tools.net.client import http_fetch
from plyngent.tools.net.client import FetchResult, http_fetch
from plyngent.tools.net.policy import (
DEFAULT_MAX_BODY_CHARS_IN,
DEFAULT_MAX_BYTES,
@@ -76,7 +77,7 @@ async def fetch_local(
follow_redirects: bool = True,
allow_http_downgrade: bool = False,
offset: int = 0,
) -> str:
) -> str | ToolOutput:
"""HTTP request (GET/POST/PUT/DELETE); return status, metadata, and truncated body.
Use for public docs/APIs or (after human policy allow) local/LAN servers.
@@ -88,6 +89,9 @@ async def fetch_local(
User-Agent entry in ``headers``. A headers-only User-Agent is kept as-is.
When both omit UA, a small default is used.
An image response (JPEG, PNG, GIF, WebP) comes back as the picture itself
beside that metadata, so you can look at it instead of reading a hex preview.
Private/loopback/link-local hosts require an explicit human policy grant
(not skipped by YOLO). HTTPS→HTTP redirects are blocked unless
``allow_http_downgrade`` is true.
@@ -121,7 +125,7 @@ async def fetch_local(
except Exception as exc: # noqa: BLE001 — tool surface returns error text
return f"error: fetch_local failed: {exc}"
return format_fetch_result(
text = format_fetch_result(
status=result.status,
final_url=result.final_url,
content_type=result.content_type,
@@ -136,6 +140,21 @@ async def fetch_local(
max_chars=max_chars,
offset=offset,
)
return _attach_image(text, result)
def _attach_image(text: str, result: FetchResult) -> str | ToolOutput:
"""An image response is handed over as a picture, beside the usual metadata.
The preview line stays in the text so ``body_kind=image`` and the byte count
are still visible to the model.
"""
if result.body_kind != "image" or not result.body_image:
return text
part = image_part_from_bytes(result.body_image)
if isinstance(part, str):
return f"{text}\n{part}"
return ToolOutput(text=text, images=[part])
# Re-export for danger classifier without importing client.
+5 -12
View File
@@ -6,6 +6,7 @@ import tempfile
from pathlib import Path
from plyngent.agent import ToolTag, tool
from plyngent.tools.trash import under_system_temp
from plyngent.tools.workspace import (
MAX_TEMPORARY_WORKSPACES,
WorkspaceError,
@@ -32,22 +33,14 @@ def _sanitize_prefix(prefix: str) -> str:
return cleaned
def _is_under_system_temp(path: Path) -> bool:
"""True if *path* is under the OS temporary directory (safe to rmtree)."""
try:
temp_root = Path(tempfile.gettempdir()).expanduser().resolve()
resolved = path.expanduser().resolve()
_ = resolved.relative_to(temp_root)
except OSError, ValueError:
return False
return True
def cleanup_temporary_workspaces() -> int:
"""Remove directories created by :func:`new_temporary_workspace` (chat exit).
Only deletes paths still under the system temp dir. Returns the number of
directories removed. No-op when no instance is bound.
These are scratch directories, so they are removed for good rather than
moved to the trash the delete tool would use.
"""
from plyngent.tools.context import get_instance
@@ -55,7 +48,7 @@ def cleanup_temporary_workspaces() -> int:
return 0
removed = 0
for path in pop_owned_temporary_workspaces():
if not _is_under_system_temp(path):
if not under_system_temp(path):
remove_workspace_allowlist(path)
continue
if path.is_dir():
+61
View File
@@ -0,0 +1,61 @@
"""Move a path to the system trash instead of destroying it.
``delete_path`` hands every removal to :func:`move_to_trash`, so a delete the
human approved (or YOLO skipped) can still be undone from the desktop's own
trash. The platform work is ``send2trash``: the freedesktop trash specification
on Linux, the Finder trash on macOS, the Recycle Bin on Windows.
Scratch paths under the OS temporary directory are the exception — they are
deleted for good, since nobody restores them and a trash on the ``/tmp`` volume
would only collect them (see :func:`under_system_temp`).
"""
from __future__ import annotations
import tempfile
from pathlib import Path
def is_available() -> bool:
"""True when ``send2trash`` can be imported.
It is a required dependency, so ``False`` means a broken install rather than
an expected absence; the delete path then reports the problem instead of
destroying the file.
"""
from importlib.util import find_spec
return find_spec("send2trash") is not None
def under_system_temp(path: Path) -> bool:
"""True if *path* is under the OS temporary directory (scratch space)."""
try:
temp_root = Path(tempfile.gettempdir()).expanduser().resolve()
resolved = path.expanduser().resolve()
_ = resolved.relative_to(temp_root)
except OSError, ValueError:
return False
return True
def move_to_trash(path: Path) -> str | None:
"""Send *path* to the system trash; return an error text, or ``None`` on success.
A failure is always reported, never downgraded to a permanent delete: the
human was told the path is recoverable, so a trash that is unavailable for
this path leaves the file untouched and says how to remove it on purpose.
"""
if not is_available():
return "error: cannot move to trash: send2trash is not installed (use permanent=true)"
from send2trash import TrashPermissionError, send2trash
try:
send2trash(str(path))
except TrashPermissionError as exc:
# A volume with no trash directory and no permission to create one.
return f"error: cannot move to trash: {exc} (use permanent=true)"
except OSError as exc:
return f"error: cannot move to trash: {exc}"
return None
+12
View File
@@ -120,6 +120,8 @@ class WorkspacePolicy:
# read them, nothing may write them. Not a grant, so they stay out of
# ``/grants``; writes go through the owning tool's own confirm.
static_read: list[Path] = field(default_factory=list)
# ``[trash] enabled``: deletes go to the system trash unless cleared.
trash_enabled: bool = True
def _bound_instance() -> InstanceState | None:
@@ -188,6 +190,16 @@ def get_path_denylist() -> tuple[str, ...]:
return active_workspace_policy().path_denylist
def set_trash_enabled(*, enabled: bool) -> None:
"""Set whether ``delete_path`` moves to the system trash (``[trash]``)."""
active_workspace_policy().trash_enabled = bool(enabled)
def get_trash_enabled() -> bool:
"""Whether deletes go to the system trash rather than being final."""
return active_workspace_policy().trash_enabled
def set_command_denylist(names: list[str] | tuple[str, ...] | frozenset[str] | None) -> None:
"""Set denied command basenames (None restores defaults)."""
policy = active_workspace_policy()
+80
View File
@@ -0,0 +1,80 @@
"""Image parts: format sniffing, data URLs, and the inline size policy."""
from __future__ import annotations
import base64
from plyngent.agent.images import (
MAX_INLINE_IMAGE_BYTES,
image_part_from_bytes,
image_part_from_url,
sniff_image_media_type,
split_data_url,
to_data_url,
user_content,
)
from plyngent.lmproto.openai_compatible.model import ChatImageUrl, ChatTextPart
PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
def test_the_format_is_sniffed_from_the_bytes() -> None:
assert sniff_image_media_type(PNG) == "image/png"
assert sniff_image_media_type(b"\xff\xd8\xff\xe0JFIF") == "image/jpeg"
assert sniff_image_media_type(b"GIF89a....") == "image/gif"
assert sniff_image_media_type(b"RIFF\x00\x00\x00\x00WEBPVP8 ") == "image/webp"
assert sniff_image_media_type(b"not an image") is None
assert sniff_image_media_type(b"") is None
def test_a_data_url_round_trips() -> None:
url = to_data_url("image/png", PNG)
assert url.startswith("data:image/png;base64,")
assert split_data_url(url) == ("image/png", base64.b64encode(PNG).decode("ascii"))
assert split_data_url("https://example.com/a.png") is None
def test_bytes_become_an_image_part_with_a_detail_level() -> None:
part = image_part_from_bytes(PNG, detail="low")
assert not isinstance(part, str)
assert part.image_url.detail == "low"
assert part.image_url.url.startswith("data:image/png;base64,")
def test_non_image_bytes_are_an_error_text() -> None:
result = image_part_from_bytes(b"plain text")
assert isinstance(result, str)
assert "not a JPEG, PNG, GIF, or WebP image" in result
def test_an_oversized_image_is_refused() -> None:
result = image_part_from_bytes(PNG + b"\x00" * (MAX_INLINE_IMAGE_BYTES + 1))
assert isinstance(result, str)
assert "inline limit" in result
def test_a_url_becomes_an_image_part() -> None:
part = image_part_from_url("https://example.com/a.jpg", detail="original")
assert part.image_url == ChatImageUrl(url="https://example.com/a.jpg", detail="original")
def test_user_content_stays_a_string_without_images() -> None:
assert user_content("hello") == "hello"
assert user_content("hello", []) == "hello"
def test_user_content_becomes_a_part_array_with_images() -> None:
part = image_part_from_url("https://example.com/a.jpg")
content = user_content("look", [part])
assert content == [ChatTextPart(text="look"), part]
# An image-only turn sends no empty text part.
assert user_content("", [part]) == [part]
+56
View File
@@ -20,10 +20,12 @@ from plyngent.agent.messages_bridge import (
)
from plyngent.lmproto.anthropic.model import (
AnthropicAssistantMessage,
AnthropicImageContent,
AnthropicMessageResponse,
AnthropicResponseText,
AnthropicResponseToolUse,
AnthropicServerToolUseContent,
AnthropicTextContent,
AnthropicThinkingContent,
AnthropicToolDefinition,
AnthropicToolResultContent,
@@ -37,6 +39,9 @@ from plyngent.lmproto.openai_compatible.model import (
AssistantFunctionTool,
AssistantFunctionToolCall,
ChatCompletionsParam,
ChatImagePart,
ChatImageUrl,
ChatTextPart,
DeveloperChatMessage,
ReasoningEffort,
SystemChatMessage,
@@ -136,6 +141,57 @@ def test_merge_consecutive_tool_results() -> None:
assert len(items[2].content) == 2
def test_a_user_turn_carries_its_images_as_blocks() -> None:
messages = [
UserChatMessage(
content=[
ChatTextPart(text="what is this?"),
ChatImagePart(image_url=ChatImageUrl(url="data:image/png;base64,AAAA", detail="low")),
ChatImagePart(image_url=ChatImageUrl(url="https://example.com/a.jpg")),
]
)
]
_, items = chat_messages_to_anthropic(messages)
assert isinstance(items[0], AnthropicUserMessage)
blocks = items[0].content
assert isinstance(blocks, list)
assert blocks[0] == AnthropicTextContent(text="what is this?")
# An inline data URL becomes a base64 source carrying the sniffed media type.
assert blocks[1] == AnthropicImageContent(source={"type": "base64", "media_type": "image/png", "data": "AAAA"})
# A public link stays a url source.
assert blocks[2] == AnthropicImageContent(source={"type": "url", "url": "https://example.com/a.jpg"})
def test_a_tool_result_carries_its_images_inside_the_result() -> None:
messages = [
UserChatMessage(content="screenshot please"),
AssistantChatMessage(
content=None,
tool_calls=[
AssistantFunctionToolCall(id="c1", function=AssistantFunctionTool(name="view_image", arguments="{}"))
],
),
ToolChatMessage(
content="image: shot.png (2 KB)",
tool_call_id="c1",
images=[ChatImagePart(image_url=ChatImageUrl(url="data:image/png;base64,AAAA"))],
),
]
_, items = chat_messages_to_anthropic(messages)
result = items[2]
assert isinstance(result, AnthropicUserMessage)
assert isinstance(result.content, list)
block = result.content[0]
assert isinstance(block, AnthropicToolResultContent)
assert block.tool_use_id == "c1"
assert block.content == [
AnthropicTextContent(text="image: shot.png (2 KB)"),
AnthropicImageContent(source={"type": "base64", "media_type": "image/png", "data": "AAAA"}),
]
def test_anthropic_response_to_assistant_with_tools() -> None:
response = AnthropicMessageResponse(
id="msg_1",
+59
View File
@@ -19,6 +19,9 @@ from plyngent.lmproto.openai_compatible.model import (
AssistantFunctionTool,
AssistantFunctionToolCall,
ChatCompletionsParam,
ChatImagePart,
ChatImageUrl,
ChatTextPart,
DeveloperChatMessage,
SystemChatMessage,
ToolChatMessage,
@@ -76,6 +79,62 @@ def test_chat_messages_to_input_and_instructions() -> None:
assert _input_item_types(items) == ["message", "reasoning", "function_call", "function_call_output"]
def test_a_user_turn_carries_its_images_as_input_parts() -> None:
messages = [
UserChatMessage(
content=[
ChatTextPart(text="what is this?"),
ChatImagePart(image_url=ChatImageUrl(url="https://example.com/a.jpg", detail="low")),
]
)
]
_, items = chat_messages_to_responses_input(messages)
assert items[0] == ResponseEasyInputMessage(
role="user",
content=[
{"type": "input_text", "text": "what is this?"},
{"type": "input_image", "image_url": "https://example.com/a.jpg", "detail": "low"},
],
)
def test_a_tool_output_carries_the_images_the_tool_returned() -> None:
messages = [
AssistantChatMessage(
content=None,
tool_calls=[
AssistantFunctionToolCall(
id="call_1", function=AssistantFunctionTool(name="view_image", arguments="{}")
)
],
),
ToolChatMessage(
content="image: shot.png (2 KB)",
tool_call_id="call_1",
images=[ChatImagePart(image_url=ChatImageUrl(url="data:image/png;base64,AAAA"))],
),
]
_, items = chat_messages_to_responses_input(messages)
output = items[-1]
assert isinstance(output, ResponseFunctionToolCallOutput)
assert output.call_id == "call_1"
assert output.output == [
{"type": "input_text", "text": "image: shot.png (2 KB)"},
{"type": "input_image", "image_url": "data:image/png;base64,AAAA"},
]
def test_a_plain_tool_output_stays_a_string() -> None:
messages = [ToolChatMessage(content="file body", tool_call_id="call_1")]
_, items = chat_messages_to_responses_input(messages)
output = items[0]
assert isinstance(output, ResponseFunctionToolCallOutput)
assert output.output == "file body"
def test_parallel_tool_outputs_group_after_the_rounds_calls() -> None:
"""Regression: DeepSeek Responses rejected an interleaved batch.
+102
View File
@@ -0,0 +1,102 @@
"""A tool result that carries images beside its text."""
from __future__ import annotations
from msgspec import UNSET
from plyngent.agent import ToolOutput, ToolRegistry, tool
from plyngent.agent.budget import IMAGE_CHARS, estimate_message_chars
from plyngent.agent.loop import _run_one_tool
from plyngent.lmproto.openai_compatible.model import (
AssistantFunctionTool,
AssistantFunctionToolCall,
ChatImagePart,
ChatImageUrl,
ChatTextPart,
ToolChatMessage,
UserChatMessage,
)
def _image() -> ChatImagePart:
return ChatImagePart(image_url=ChatImageUrl(url="data:image/png;base64,AAAA", detail="low"))
def _registry() -> ToolRegistry:
@tool(register=False)
def shot() -> ToolOutput:
"""Pretend to take a screenshot."""
return ToolOutput(text="image: shot.png (2 KB)", images=[_image()])
@tool(register=False)
def plain() -> str:
return "just text"
return ToolRegistry([shot, plain])
def _call(name: str) -> AssistantFunctionToolCall:
return AssistantFunctionToolCall(id="call-1", function=AssistantFunctionTool(name=name, arguments="{}"))
async def test_execute_keeps_the_text_only_contract() -> None:
registry = _registry()
assert await registry.execute("shot", "{}") == "image: shot.png (2 KB)"
assert await registry.execute("plain", "{}") == "just text"
async def test_execute_output_returns_the_images_beside_the_text() -> None:
registry = _registry()
output = await registry.execute_output("shot", "{}")
assert output.text == "image: shot.png (2 KB)"
assert output.has_images
assert output.images == [_image()]
async def test_execute_output_wraps_a_plain_string_result() -> None:
output = await _registry().execute_output("plain", "{}")
assert output.text == "just text"
assert not output.has_images
async def test_an_unknown_tool_is_error_text_without_images() -> None:
output = await _registry().execute_output("missing", "{}")
assert "unknown tool" in output.text
assert not output.has_images
async def test_the_loop_keeps_tool_images_on_the_tool_message() -> None:
message, err = await _run_one_tool(_registry(), _call("shot"), max_result_chars=32_000)
assert err is None
assert isinstance(message, ToolChatMessage)
assert message.content == "image: shot.png (2 KB)"
assert message.images == [_image()]
async def test_the_loop_leaves_the_images_field_unset_for_text_tools() -> None:
message, err = await _run_one_tool(_registry(), _call("plain"), max_result_chars=32_000)
assert err is None
assert message.images is UNSET
async def test_a_truncated_tool_result_keeps_its_images() -> None:
message, _ = await _run_one_tool(_registry(), _call("shot"), max_result_chars=1)
assert "[Truncated" in message.content
assert message.images == [_image()]
def test_the_char_estimate_counts_images() -> None:
assert estimate_message_chars(UserChatMessage(content="look:")) == len("look:")
assert estimate_message_chars(UserChatMessage(content=[ChatTextPart(text="look:"), _image()])) == (
len("look:") + IMAGE_CHARS
)
assert estimate_message_chars(ToolChatMessage(content="shot", tool_call_id="call-1", images=[_image()])) == (
len("shot") + len("call-1") + IMAGE_CHARS
)
@@ -93,6 +93,46 @@ async def test_round_asks_once_for_many_mutating_calls() -> None:
assert single_prompts == []
async def test_yolo_calls_are_left_out_of_the_prompt() -> None:
"""Under YOLO mode a YOLO-tagged call runs unprompted, batch prompt included."""
batch_prompts: list[list[str]] = []
single_prompts: list[str] = []
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
async def yolo_ok(path: str) -> str:
return f"stamped {path}"
@tool(tags=ToolTag.LOCAL, register=False)
async def always_ask(path: str) -> str:
return f"stamped {path}"
def on_batch(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
batch_prompts.append([request.name for request in requests])
return BatchConfirmDecision(allowed=True)
async def on_single(name: str, _args: Mapping[str, object], _reason: str) -> bool:
single_prompts.append(name)
return True
reg = ToolRegistry(
[yolo_ok, always_ask],
danger=_danger,
on_confirm=on_single,
on_confirm_batch=on_batch,
yolo=True,
)
calls = [("yolo_ok", '{"path": "a"}'), ("always_ask", '{"path": "b"}'), ("always_ask", '{"path": "c"}')]
decisions = await reg.prepare_batch_confirm(calls)
assert decisions[0] is None
assert batch_prompts == [["always_ask", "always_ask"]]
results = [
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
]
assert results == ["stamped a", "stamped b", "stamped c"]
assert single_prompts == []
async def test_single_request_keeps_the_one_call_prompt() -> None:
"""One mutating call stays the plain per-call confirm."""
batch_prompts: list[int] = []
+6
View File
@@ -657,6 +657,12 @@ async def test_pretty_mutators(capsys: pytest.CaptureFixture[str]) -> None:
),
("move_path", '{"src": "src", "dst": "dst"}', "moved directory src -> dst", "* Move 'src' → 'dst' (done)"),
("delete_path", '{"path": "tmp/x.txt"}', "deleted file tmp/x.txt", "* Delete 'tmp/x.txt' (done)"),
(
"delete_path",
'{"path": "tmp/x.txt"}',
"deleted file tmp/x.txt (moved to system trash)",
"* Delete 'tmp/x.txt' (to trash)",
),
]
for name, args, content, expected in cases:
await render_events(_aiter([_pretty_call(name, args), _result(content)]))
+5 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
@@ -147,7 +148,10 @@ async def test_config_allow_paths_installed(
) -> None:
outside = (tmp_path.parent / "outside-config").resolve()
outside.mkdir(exist_ok=True)
document = tomlkit.parse(f'[agent]\nallow_paths = {{ "{outside}" = "read", "/does/not/exist" = "read" }}\n')
# JSON string escaping is TOML escaping too, so a Windows path's backslashes
# survive instead of reading as an escape (``\u``).
key = json.dumps(str(outside))
document = tomlkit.parse(f'[agent]\nallow_paths = {{ {key} = "read", "/does/not/exist" = "read" }}\n')
state, memory = await _new_state(tmp_path, document=document)
try:
assert state.instance_state.workspace.config_allow == {outside: AccessMode.READ}
+9
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import os
import signal
import sys
import threading
import time
from typing import TYPE_CHECKING
@@ -35,6 +36,14 @@ if TYPE_CHECKING:
import pytest as pytest_module
# The router is asyncio's SIGINT handler, which only exists where signals do; on
# Windows a test could not deliver one either — ``os.kill(pid, SIGINT)`` is
# ``TerminateProcess`` there, so it kills the runner instead of interrupting it.
pytestmark = pytest.mark.skipif(
sys.platform == "win32",
reason="SIGINT routing needs POSIX signal handling",
)
class FakeTarget:
"""Minimal ``InterruptTarget`` recording its cancels."""
+149 -8
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING
import pytest
@@ -15,12 +16,18 @@ from plyngent.cli.limits import (
reset_auto_continue_turn,
set_auto_continue_default,
)
from plyngent.prompting import NonInteractiveBackend, get_prompt_backend, get_prompt_output_hook, temporary_backend
from plyngent.prompting import (
NonInteractiveBackend,
get_prompt_backend,
get_prompt_output_hook,
get_prompt_wait_hook,
temporary_backend,
)
from plyngent.tools.process.pty_session import PtyManager
from tests.test_prompting import ScriptedBackend
if TYPE_CHECKING:
from collections.abc import Iterator
from collections.abc import Generator, Iterator
@pytest.fixture
@@ -84,13 +91,28 @@ def test_prompt_continue_limit_noninteractive_denies() -> None:
def test_setup_hooks_installs_pty_hook_for_auto_continue(clean_auto_continue: None) -> None:
del clean_auto_continue
from plyngent.cli.app import _setup_hooks
from plyngent.config.models import NotifyConfig
_setup_hooks(interactive=False, auto_continue=True)
_setup_hooks(interactive=False, auto_continue=True, notify=NotifyConfig())
assert PtyManager._limit_continue is not None
_setup_hooks(interactive=False, auto_continue=False)
_setup_hooks(interactive=False, auto_continue=False, notify=NotifyConfig())
assert PtyManager._limit_continue is None
def test_setup_hooks_installs_the_notifier_only_for_an_interactive_run(clean_auto_continue: None) -> None:
"""A one-shot run never waits on a human, so it installs no wait hook."""
del clean_auto_continue
from plyngent.cli.app import _setup_hooks
from plyngent.config.models import NotifyConfig
_setup_hooks(interactive=False, auto_continue=False, notify=NotifyConfig())
assert get_prompt_wait_hook() is None
_setup_hooks(interactive=True, auto_continue=False, notify=NotifyConfig())
assert get_prompt_wait_hook() is not None
_setup_hooks(interactive=False, auto_continue=False, notify=NotifyConfig())
assert get_prompt_wait_hook() is None
def test_pty_offer_raise_uses_auto_continue(clean_auto_continue: None) -> None:
del clean_auto_continue
PtyManager.set_limit_continue_hook(prompt_continue_limit)
@@ -157,15 +179,134 @@ def test_directory_access_prompt_yes_and_level(monkeypatch: pytest.MonkeyPatch)
target = Path("/tmp")
with temporary_backend(ScriptedBackend([])):
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "y\n")
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "y\n")
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == AccessDecision(
AccessMode.READ
)
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "exec\n")
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "exec\n")
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == AccessDecision(
AccessMode.EXEC
)
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "n\n")
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "n\n")
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) is None
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: None)
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: None)
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) is None
class _AnswerSource:
"""A host surface offering answers to a waiting prompt."""
supports_actions = True
def __init__(self, *answers: str) -> None:
self._answers = list(answers)
def take(self) -> str | None:
return self._answers.pop(0) if self._answers else None
@contextlib.contextmanager
def _answered(*answers: str) -> Generator[_AnswerSource]:
"""Install a wait hook whose prompt can be answered by the notification."""
from plyngent.prompting import set_prompt_wait_hook
source = _AnswerSource(*answers)
previous = get_prompt_wait_hook()
set_prompt_wait_hook(lambda _wait: contextlib.nullcontext(source))
try:
yield source
finally:
set_prompt_wait_hook(previous)
def test_directory_access_takes_a_notification_answer() -> None:
"""``allow`` from the notification approves the requested level."""
from pathlib import Path
from plyngent.cli import limits
from plyngent.tools.access import AccessDecision
from plyngent.tools.workspace import AccessMode
with temporary_backend(ScriptedBackend([])), _answered("allow"):
assert limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0) == AccessDecision(
AccessMode.READ
)
def test_directory_access_denied_by_the_notification() -> None:
from pathlib import Path
from plyngent.cli import limits
from plyngent.tools.workspace import AccessMode
with temporary_backend(ScriptedBackend([])), _answered("deny"):
assert limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0) is None
def test_tool_confirm_takes_a_notification_answer() -> None:
with temporary_backend(ScriptedBackend([])), _answered("allow"):
assert prompt_confirm_tool("run_argv", {}, "runs a command") is True
def test_tool_confirm_denied_by_the_notification() -> None:
with temporary_backend(ScriptedBackend([""])), _answered("deny"):
assert prompt_confirm_tool("run_argv", {}, "runs a command") is False
def test_batch_confirm_takes_a_notification_answer() -> None:
from plyngent.agent import ToolConfirmRequest
from plyngent.cli.limits import _prompt_confirm_batch_sync
requests = [
ToolConfirmRequest(name="run_argv", args={}, reason="one"),
ToolConfirmRequest(name="run_argv", args={}, reason="two"),
]
with temporary_backend(ScriptedBackend([])), _answered("allow"):
assert _prompt_confirm_batch_sync(requests).allowed is True
def test_batch_confirm_denied_by_the_notification() -> None:
from plyngent.agent import ToolConfirmRequest
from plyngent.cli.limits import _prompt_confirm_batch_sync
requests = [
ToolConfirmRequest(name="run_argv", args={}, reason="one"),
ToolConfirmRequest(name="run_argv", args={}, reason="two"),
]
with temporary_backend(ScriptedBackend([""])), _answered("deny"):
assert _prompt_confirm_batch_sync(requests).allowed is False
def test_every_confirm_announces_itself() -> None:
"""Each blocking confirm reports its kind, so a host can pick a surface."""
from pathlib import Path
from plyngent.agent import ToolConfirmRequest
from plyngent.cli import limits
from plyngent.tools.workspace import AccessMode
kinds: list[str] = []
@contextlib.contextmanager
def hook(wait: object) -> Generator[None]:
kinds.append(wait.kind) # type: ignore[attr-defined]
yield None
from plyngent.prompting import set_prompt_wait_hook
previous = get_prompt_wait_hook()
set_prompt_wait_hook(hook)
try:
with temporary_backend(ScriptedBackend(["y", "y", "y"], confirms=[True])):
_ = prompt_confirm_tool("run_argv", {}, "reason")
_ = limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0)
_ = limits._prompt_confirm_batch_sync(
[
ToolConfirmRequest(name="run_argv", args={}, reason="one"),
ToolConfirmRequest(name="run_argv", args={}, reason="two"),
]
)
_ = prompt_continue_limit("limit")
finally:
set_prompt_wait_hook(previous)
assert kinds == ["confirm", "access", "batch", "limit"]
+2 -2
View File
@@ -68,7 +68,7 @@ async def test_slash_mcp_list_statuses(state: ReplState, capsys: pytest.CaptureF
out = capsys.readouterr().out
assert "fake" in out
assert "connected" in out
assert "tools=3" in out
assert "tools=4" in out
finally:
await manager.aclose()
@@ -92,7 +92,7 @@ async def test_slash_mcp_reconnect_connects_new_config(
statuses = state.mcp_manager.statuses()
assert [name for name, _, _ in statuses] == ["fake"]
assert statuses[0][1] == "connected"
assert statuses[0][2] == 3
assert statuses[0][2] == 4
out = capsys.readouterr().out
assert "tools=off" in out
assert "fake" in out
+88
View File
@@ -0,0 +1,88 @@
"""``@path`` mentions: parsing, token spelling, and Tab completion."""
from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.cli.mentions import (
complete_mention_paths,
mention_completions,
mention_token_for,
parse_mentions,
)
if TYPE_CHECKING:
from pathlib import Path
def _paths(line: str) -> list[str]:
return [mention.path for mention in parse_mentions(line)]
def test_a_mention_is_a_whitespace_delimited_token() -> None:
assert _paths("look at @shot.png please") == ["shot.png"]
assert _paths("@a.png and @b/c.txt") == ["a.png", "b/c.txt"]
assert _paths("@a.png\n@b.png") == ["a.png", "b.png"]
def test_an_email_address_is_not_a_mention() -> None:
assert _paths("mail me at someone@example.com") == []
def test_a_quoted_mention_may_hold_spaces() -> None:
assert _paths('look at @"screens/my shot.png"') == ["screens/my shot.png"]
assert _paths('@"a b.png" and @c.png') == ["a b.png", "c.png"]
def test_an_unterminated_quote_is_left_alone() -> None:
assert _paths('still typing @"my sh') == []
def test_a_bare_at_sign_is_not_a_mention() -> None:
assert _paths("@ and @@@") == []
def test_a_token_quotes_a_path_with_spaces() -> None:
assert mention_token_for("shot.png") == "@shot.png"
assert mention_token_for("my shot.png") == '@"my shot.png"'
def test_completion_lists_a_directory(tmp_path: Path) -> None:
(tmp_path / "shot.png").write_bytes(b"")
(tmp_path / "screens").mkdir()
completions = complete_mention_paths("sc", cwd=tmp_path)
assert completions == ["screens/"]
def test_completion_continues_through_a_directory(tmp_path: Path) -> None:
(tmp_path / "screens").mkdir()
(tmp_path / "screens" / "shot.png").write_bytes(b"")
assert complete_mention_paths("screens/sh", cwd=tmp_path) == ["screens/shot.png"]
assert complete_mention_paths("screens/", cwd=tmp_path) == ["screens/shot.png"]
def test_completion_hides_dot_files_unless_asked(tmp_path: Path) -> None:
(tmp_path / ".env").write_text("x", encoding="utf-8")
assert complete_mention_paths("", cwd=tmp_path) == []
assert complete_mention_paths(".", cwd=tmp_path) == [".env"]
def test_completion_ignores_a_missing_directory(tmp_path: Path) -> None:
assert complete_mention_paths("nope/x", cwd=tmp_path) == []
def test_completions_keep_the_at_sign_and_quote_spaces(tmp_path: Path) -> None:
(tmp_path / "my shot.png").write_bytes(b"")
(tmp_path / "shots").mkdir()
assert mention_completions("@my", cwd=tmp_path) == ['@"my shot.png"']
assert mention_completions("@shots", cwd=tmp_path) == ["@shots/"]
assert mention_completions("@", cwd=tmp_path) == ['@"my shot.png"', "@shots/"]
def test_mention_completions_ignores_a_plain_word() -> None:
assert mention_completions("shot") == []
+383
View File
@@ -0,0 +1,383 @@
from __future__ import annotations
import time
from typing import TYPE_CHECKING
import pytest
from plyngent.cli.notify import (
ALLOW,
DENY,
REVIEW,
DesktopRequest,
WaitNotifier,
detect_multiplexer,
detect_protocol,
install_notifier,
terminal_sequence,
)
from plyngent.config.models import NotifyConfig
from plyngent.prompting import (
PromptWait,
configure_prompting,
get_prompt_wait_hook,
reset_prompting,
temporary_backend,
)
if TYPE_CHECKING:
from collections.abc import Iterator
class _InteractiveBackend:
"""An interactive backend that never actually reads (the prompts do not run)."""
def is_interactive(self) -> bool:
return True
def read_line(self, prompt: str, *, default: str | None = None, completions: object = None) -> str:
del prompt, completions
return default or ""
def read_secret_line(self, prompt: str) -> str:
del prompt
return ""
def confirm(self, prompt: str, *, default: bool = False) -> bool:
del prompt
return default
def echo(self, message: str = "", *, err: bool = False) -> None:
del message, err
def secho(self, message: str, *, fg: str | None = None, err: bool = False) -> None:
del message, fg, err
class _FakeDesktop:
"""A desktop channel that records what it was asked to show."""
def __init__(
self,
capabilities: frozenset[str] | None = None,
*,
fail: bool = False,
) -> None:
self.caps = frozenset({"buttons", "reply"}) if capabilities is None else capabilities
self.fail = fail
self.requests: list[DesktopRequest] = []
self.cleared: list[str | None] = []
def capabilities(self) -> frozenset[str]:
return self.caps
def send(self, request: DesktopRequest) -> str | None:
if self.fail:
msg = "no notification server"
raise RuntimeError(msg)
self.requests.append(request)
return "id-1"
def clear(self, identifier: str | None) -> None:
self.cleared.append(identifier)
@pytest.fixture(autouse=True)
def _prompting_state() -> Iterator[None]:
reset_prompting()
configure_prompting(backend=_InteractiveBackend())
yield
reset_prompting()
def _notifier(
*,
desktop: _FakeDesktop | None = None,
grace_seconds: float = 60.0,
min_interval_seconds: float = 0.0,
**kwargs: object,
) -> tuple[WaitNotifier, list[str]]:
"""A notifier with a captured terminal writer and a fake desktop channel."""
written: list[str] = []
config = NotifyConfig(
grace_seconds=grace_seconds,
min_interval_seconds=min_interval_seconds,
**kwargs, # type: ignore[arg-type]
)
notifier = WaitNotifier(
config,
desktop=desktop,
write=written.append,
env={"TERM_PROGRAM": "WezTerm"},
)
return notifier, written
# --------------------------------------------------------------------------- #
# Terminal channel
# --------------------------------------------------------------------------- #
def test_detect_protocol_reads_the_environment() -> None:
assert detect_protocol({"TERM_PROGRAM": "iTerm.app"}) == "osc9"
assert detect_protocol({"TERM_PROGRAM": "Apple_Terminal"}) == "osc9"
assert detect_protocol({"TERM_PROGRAM": "WezTerm"}) == "osc777"
assert detect_protocol({"TERM_PROGRAM": "ghostty"}) == "osc777"
assert detect_protocol({"WT_SESSION": "1"}) == "osc777"
assert detect_protocol({"VTE_VERSION": "5000"}) == "osc777"
assert detect_protocol({"TERM": "rxvt-unicode"}) == "osc777"
# kitty speaks OSC 99 only, which this module does not implement.
assert detect_protocol({"KITTY_WINDOW_ID": "1"}) == "bell"
assert detect_protocol({"TERM": "dumb"}) == "bell"
def test_detect_protocol_honours_the_override() -> None:
assert detect_protocol({"TERM_PROGRAM": "WezTerm"}, "osc9") == "osc9"
assert detect_protocol({"TERM_PROGRAM": "iTerm.app"}, "bell") == "bell"
# An unknown override falls back to detection.
assert detect_protocol({"TERM_PROGRAM": "WezTerm"}, "osc99") == "osc777"
def test_detect_multiplexer() -> None:
assert detect_multiplexer({"TMUX": "/tmp/tmux-1000/default,1,0"}) == "tmux"
assert detect_multiplexer({"STY": "1234.pts-0.host"}) == "screen"
assert detect_multiplexer({}) == ""
def test_terminal_sequence_osc777_and_osc9() -> None:
assert terminal_sequence("osc777", "T", "M") == "\x1b]777;notify;T;M\x07"
assert terminal_sequence("osc9", "T", "M") == "\x1b]9;T: M\x07"
assert terminal_sequence("bell", "T", "M") == "\a"
def test_terminal_sequence_is_wrapped_for_tmux() -> None:
seq = terminal_sequence("osc777", "T", "M", multiplexer="tmux")
assert seq.startswith("\x1bPtmux;\x1b")
assert seq.endswith("\x1b\\")
# The inner ESCs are doubled so tmux forwards them verbatim.
assert "\x1b\x1b]777" in seq
def test_terminal_sequence_strips_control_characters() -> None:
seq = terminal_sequence("osc777", "a\x07b\nc", "d\te")
assert "\x07" not in seq.removesuffix("\x07")
assert "ab c" in seq
assert "\n" not in seq
# --------------------------------------------------------------------------- #
# Grace, throttle, channels
# --------------------------------------------------------------------------- #
def test_nothing_is_shown_before_the_grace_period() -> None:
notifier, written = _notifier(grace_seconds=0.05)
with notifier(PromptWait("confirm", "Allow?")):
pass
time.sleep(0.1)
assert written == []
def test_a_waited_prompt_is_announced() -> None:
notifier, written = _notifier(grace_seconds=0.01)
with notifier(PromptWait("confirm", "Allow?")) as handle:
deadline = time.monotonic() + 1.0
while not written and time.monotonic() < deadline:
time.sleep(0.01)
assert handle.supports_actions is False # no desktop channel here
assert written
assert "777" in written[0]
assert "Allow?" in written[0]
def test_terminal_channel_can_be_pinned() -> None:
desktop = _FakeDesktop()
notifier, written = _notifier(desktop=desktop, channels=["terminal"])
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert desktop.requests == []
assert written
def test_desktop_channel_can_be_pinned() -> None:
desktop = _FakeDesktop()
notifier, written = _notifier(desktop=desktop, channels=["desktop"])
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert len(desktop.requests) == 1
assert written == []
def test_terminal_fallback_when_the_desktop_refuses() -> None:
desktop = _FakeDesktop(fail=True)
notifier, written = _notifier(desktop=desktop)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert written
def test_a_burst_is_throttled() -> None:
notifier, written = _notifier(grace_seconds=0.0, min_interval_seconds=60.0)
for _ in range(2):
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert len(written) == 1
def test_nothing_is_shown_on_a_non_interactive_prompt() -> None:
from plyngent.prompting import NonInteractiveBackend
notifier, written = _notifier()
configure_prompting(backend=NonInteractiveBackend())
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert written == []
def test_disabled_notifications_send_nothing() -> None:
notifier, written = _notifier(grace_seconds=0.0, enabled=False)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert written == []
# --------------------------------------------------------------------------- #
# Answers
# --------------------------------------------------------------------------- #
def test_a_confirmation_offers_three_buttons() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert handle.supports_actions is True
request = desktop.requests[0]
assert [key for key, _ in request.buttons] == [ALLOW, DENY, REVIEW]
assert request.reply_field is False
def test_an_ask_offers_a_reply_field_instead_of_buttons() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("ask", "Which branch?")) as handle:
handle._notify()
request = desktop.requests[0]
assert request.reply_field is True
assert request.buttons == ()
def test_a_limit_prompt_carries_no_answers() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("limit", "Round limit reached")) as handle:
handle._notify()
assert handle.supports_actions is False
assert desktop.requests[0].buttons == ()
assert desktop.requests[0].reply_field is False
def test_actions_can_be_switched_off() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop, actions=False)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert handle.supports_actions is False
assert desktop.requests[0].buttons == ()
def test_platform_without_buttons_gets_a_text_notification() -> None:
desktop = _FakeDesktop(capabilities=frozenset())
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert handle.supports_actions is False
assert desktop.requests[0].buttons == ()
def test_a_button_answer_reaches_the_prompt() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
request = desktop.requests[0]
assert request.on_answer is not None
request.on_answer(ALLOW)
assert handle.take() == ALLOW
assert handle.take() is None
def test_a_reply_reaches_the_prompt() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("ask", "Which branch?")) as handle:
handle._notify()
request = desktop.requests[0]
assert request.on_reply is not None
request.on_reply("feature/x")
assert handle.take() == "feature/x"
def test_review_keeps_waiting_and_dismisses() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
request = desktop.requests[0]
assert request.on_answer is not None
request.on_answer(REVIEW)
assert handle.take() is None
assert desktop.cleared == ["id-1"]
def test_an_answer_after_the_prompt_is_ignored() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
request = desktop.requests[0]
assert request.on_answer is not None
request.on_answer(ALLOW)
assert handle.take() is None
def test_the_notification_is_dismissed_when_the_prompt_returns() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert handle.identifier == "id-1"
assert handle.identifier is None
assert desktop.cleared == ["id-1"]
# --------------------------------------------------------------------------- #
# Installation
# --------------------------------------------------------------------------- #
def test_install_notifier_sets_the_hook() -> None:
notifier = install_notifier(NotifyConfig())
assert notifier is not None
assert get_prompt_wait_hook() is not None
def test_install_notifier_can_be_disabled() -> None:
assert install_notifier(NotifyConfig(enabled=False)) is None
assert get_prompt_wait_hook() is None
def test_reason_stays_out_unless_asked_for() -> None:
notifier, _ = _notifier()
wait = PromptWait("confirm", "Allow?", "argv: rm -rf /")
assert notifier._message(wait) == "Allow?"
notifier, _ = _notifier(grace_seconds=0.0, include_reason=True)
assert "rm -rf" in notifier._message(wait)
def test_temporary_backend_is_transparent_to_the_notifier() -> None:
"""The notifier is only a hook: without installation nothing is shown."""
notifier, written = _notifier()
with temporary_backend(_InteractiveBackend()), notifier(PromptWait("confirm", "Allow?")):
pass
assert written == []
+40
View File
@@ -170,6 +170,46 @@ def test_completer_stream_on_off(tmp_path: object, monkeypatch: object) -> None:
assert completer("o", 1) == "off"
def test_completer_path_mentions(tmp_path: object, monkeypatch: object) -> None:
"""An ``@path`` token completes files, wherever it sits in the line."""
import readline
from pathlib import Path
import pytest
assert isinstance(tmp_path, Path)
assert isinstance(monkeypatch, pytest.MonkeyPatch)
(tmp_path / "shot.png").write_bytes(b"")
(tmp_path / "screens").mkdir()
state = _minimal_state(tmp_path)
completer = build_completer(state)
monkeypatch.setattr(readline, "get_line_buffer", lambda: "look at @sh")
monkeypatch.setattr(readline, "get_begidx", lambda: len("look at "))
monkeypatch.chdir(tmp_path)
found = [item for index in range(10) if (item := completer("@sh", index)) is not None]
assert found == ["@shot.png"]
def test_a_plain_word_is_still_a_slash_argument(tmp_path: object, monkeypatch: object) -> None:
"""The mention branch must not swallow ordinary argument completion."""
import readline
from pathlib import Path
import pytest
assert isinstance(tmp_path, Path)
assert isinstance(monkeypatch, pytest.MonkeyPatch)
state = _minimal_state(tmp_path)
completer = build_completer(state)
monkeypatch.setattr(readline, "get_line_buffer", lambda: "/provider r")
monkeypatch.setattr(readline, "get_begidx", lambda: len("/provider "))
assert completer("r", 0) == "remote"
def test_complete_slash_args_from_registry(tmp_path: object) -> None:
from pathlib import Path
+80 -5
View File
@@ -1,14 +1,17 @@
from __future__ import annotations
import base64
from typing import TYPE_CHECKING, Literal, overload
import pytest
import tomlkit
from plyngent.agent import ChatAgent, Notice
from plyngent.cli.mentions import resolve_mentions
from plyngent.cli.repl import _send_turn
from plyngent.cli.slash import handle_slash
from plyngent.cli.state import ReplState
from plyngent.config.models import DatabaseConfig, OpenAIProvider
from plyngent.config.models import DatabaseConfig, ModelConfig, OpenAIProvider
from plyngent.config.store import ConfigStore
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
@@ -16,6 +19,9 @@ from plyngent.lmproto.openai_compatible.model import (
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
ChatImagePart,
ChatTextPart,
UserChatMessage,
)
from plyngent.memory import MemoryStore
@@ -24,6 +30,17 @@ if TYPE_CHECKING:
from pathlib import Path
# A real 1x1 PNG: ``/image`` sniffs the format from these bytes.
_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
def _accept_images(state: ReplState) -> None:
"""Mark the fixture's model as taking pictures (the config default is text-only)."""
state.provider.models[state.model] = ModelConfig(image_in=True)
class DummyClient:
@overload
async def chat_completions(
@@ -469,8 +486,8 @@ async def test_yolo_rebuilds_tool_registry(tmp_path: Path) -> None:
)
try:
assert st.agent.tools is not None
# Soft-confirm hooks stay installed; YOLO mode does not bypass
# dangerous invocations — internal danger classification takes precedence.
# Soft-confirm hooks stay installed; YOLO mode only auto-approves the
# confirms of YOLO-tagged tools, so the others still prompt.
assert st.agent.tools.soft_confirm is True
assert st.agent.tools.yolo is False
st.set_yolo("on")
@@ -543,7 +560,7 @@ async def test_repl_ctrl_c_from_turn_returns_to_prompt(
raise EOFError
return "hello"
async def interrupted_turn(_agent: object, _text: str) -> bool:
async def interrupted_turn(_agent: object, _text: str, **_kwargs: object) -> bool:
raise KeyboardInterrupt
monkeypatch.setattr(repl_mod, "setup_readline", lambda _state: None)
@@ -573,7 +590,7 @@ async def test_repl_second_ctrl_c_after_cancel_returns_to_prompt(
raise EOFError
return reads[calls["reads"] - 1]
async def cancel_turn(_agent: object, _text: str) -> bool:
async def cancel_turn(_agent: object, _text: str, **_kwargs: object) -> bool:
raise KeyboardInterrupt # first Ctrl+C: cancels every turn
def echo_user(text: str) -> None:
@@ -780,3 +797,61 @@ async def test_todos_update_takes_a_listing_number(state: ReplState, capsys: pyt
assert await handle_slash(state, "/todos pop") is True
assert "popped TOP group (first, second)" in capsys.readouterr().out
async def test_a_mention_attaches_the_picture_to_the_turn(state: ReplState, tmp_path: Path) -> None:
_accept_images(state)
shot = tmp_path / "shot.png"
_ = shot.write_bytes(_PNG)
state.agent.stream = False
payload = resolve_mentions(state, f"look at @{shot}")
assert payload.notes == []
assert len(payload.images) == 1
assert payload.text == f"look at @{shot}"
await _send_turn(state, payload.text)
user = next(m for m in state.agent.messages if isinstance(m, UserChatMessage))
assert isinstance(user.content, list)
text_part, image_part = user.content
assert isinstance(text_part, ChatTextPart)
assert text_part.text == f"look at @{shot}"
assert isinstance(image_part, ChatImagePart)
assert image_part.image_url.url.startswith("data:image/png;base64,")
async def test_a_mention_inlines_a_text_file(state: ReplState, tmp_path: Path) -> None:
notes = tmp_path / "notes.txt"
_ = notes.write_text("the deploy needs acme", encoding="utf-8")
payload = resolve_mentions(state, f"read @{notes}")
assert payload.images == []
assert payload.notes == []
assert payload.text.startswith(f"read @{notes}")
assert f"--- @{notes} (21 chars) ---" in payload.text
assert payload.text.endswith("the deploy needs acme")
async def test_a_mention_of_a_missing_file_is_a_note(state: ReplState, tmp_path: Path) -> None:
payload = resolve_mentions(state, "@nope.png is gone")
assert payload.images == []
assert payload.notes == ["@nope.png: file not found"]
assert payload.text == "@nope.png is gone"
async def test_a_picture_mention_is_skipped_for_a_text_only_model(
state: ReplState,
tmp_path: Path,
) -> None:
"""``image_in`` is the gate: a text-only model never receives a picture."""
shot = tmp_path / "shot.png"
_ = shot.write_bytes(_PNG)
payload = resolve_mentions(state, f"@{shot}")
assert payload.images == []
note = payload.notes[0]
assert "does not accept image input" in note
assert "image_in = true" in note
+10 -7
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
@@ -44,7 +45,9 @@ def _skill_notice_rows(state: ReplState) -> list[str]:
def _config_doc(tmp_path: Path, *, paths: list[Path] | None = None, extra: str = "") -> tomlkit.TOMLDocument:
lines = ["[skills]", "discover = []"]
if paths:
rendered = ", ".join(f'"{path}"' for path in paths)
# JSON string escaping is TOML escaping too, so a Windows path's
# backslashes survive instead of reading as an escape (``\u``).
rendered = ", ".join(json.dumps(str(path)) for path in paths)
lines.append(f"paths = [{rendered}]")
if extra:
lines.append(extra)
@@ -215,12 +218,12 @@ async def test_skill_tools_are_offered_and_can_read_skill_files(
skill_file = skills_state.skills.skills[0].skill_file if skills_state.skills else None
assert skill_file is not None
read = await skills_state.agent.tools.execute("read_file", f'{{"path": "{skill_file}"}}')
read = await skills_state.agent.tools.execute("read_file", json.dumps({"path": str(skill_file)}))
assert "Deploy the acme stack" in read
denied = await skills_state.agent.tools.execute(
"write_file",
f'{{"path": "{skill_file}", "content": "x"}}',
json.dumps({"path": str(skill_file), "content": "x"}),
)
assert denied.startswith("error:")
assert "escapes workspace root" in denied
@@ -310,13 +313,13 @@ def test_prompt_skill_write_confirm_answers(monkeypatch: pytest.MonkeyPatch) ->
target = Path("/skills/acme")
with temporary_backend(ScriptedBackend([])):
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "o\n")
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "o\n")
once = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "s\n")
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "s\n")
session = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "n\n")
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "n\n")
denied = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: None)
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: None)
timed_out = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
assert (once.allow, once.session) == (True, False)
assert (session.allow, session.session) == (True, True)
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.config import load
if TYPE_CHECKING:
from pathlib import Path
def test_notify_section_defaults(tmp_path: Path) -> None:
"""Notifications are on by default: a five-second grace, desktop first."""
path = tmp_path / "c.toml"
_ = path.write_text("", encoding="utf-8")
cfg = load(path).notify_config
assert cfg.enabled is True
assert cfg.grace_seconds == 5.0
assert cfg.min_interval_seconds == 10.0
assert cfg.channels == ["auto"]
assert cfg.actions is True
assert cfg.include_reason is False
assert cfg.protocol == ""
def test_notify_section_parse(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text(
"""
[notify]
enabled = false
grace_seconds = 2.5
min_interval_seconds = 30.0
channels = ["terminal"]
actions = false
include_reason = true
protocol = "osc9"
""",
encoding="utf-8",
)
cfg = load(path).notify_config
assert cfg.enabled is False
assert cfg.grace_seconds == 2.5
assert cfg.min_interval_seconds == 30.0
assert cfg.channels == ["terminal"]
assert cfg.actions is False
assert cfg.include_reason is True
assert cfg.protocol == "osc9"
def test_notify_section_typo_falls_back(tmp_path: Path) -> None:
"""An unknown key or a bad type degrades to the defaults, like ``[mcp]``."""
path = tmp_path / "c.toml"
_ = path.write_text(
"""
[notify]
grace = 1.0
""",
encoding="utf-8",
)
assert load(path).notify_config.grace_seconds == 5.0
path2 = tmp_path / "d.toml"
_ = path2.write_text(
"""
[notify]
enabled = "yes"
""",
encoding="utf-8",
)
assert load(path2).notify_config.enabled is True
def test_notify_section_survives_reload(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text("[notify]\nenabled = false\n", encoding="utf-8")
store = load(path)
assert store.notify_config.enabled is False
_ = path.write_text("[notify]\nenabled = true\ngrace_seconds = 1.0\n", encoding="utf-8")
store.reload()
assert store.notify_config.enabled is True
assert store.notify_config.grace_seconds == 1.0
+11
View File
@@ -40,6 +40,17 @@ allow_write = true
assert cfg.allow_write is True
def test_skills_section_survives_reload(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text('[skills]\npaths = ["~/a"]\n', encoding="utf-8")
store = load(path)
assert store.skills_config.paths == ["~/a"]
_ = path.write_text("[skills]\nenabled = false\npaths = []\n", encoding="utf-8")
store.reload()
assert store.skills_config.enabled is False
assert store.skills_config.paths == []
def test_skills_section_invalid_falls_back(tmp_path: Path) -> None:
"""An unknown discovery source or a bad type degrades to the defaults."""
path = tmp_path / "c.toml"
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.config import load
if TYPE_CHECKING:
from pathlib import Path
def test_trash_section_defaults(tmp_path: Path) -> None:
"""Deletes go to the system trash unless the section says otherwise."""
path = tmp_path / "c.toml"
_ = path.write_text("", encoding="utf-8")
assert load(path).trash_config.enabled is True
def test_trash_section_parse(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text("[trash]\nenabled = false\n", encoding="utf-8")
assert load(path).trash_config.enabled is False
def test_trash_section_typo_falls_back(tmp_path: Path) -> None:
"""An unknown key or a bad type degrades to the defaults, like ``[mcp]``."""
path = tmp_path / "c.toml"
_ = path.write_text("[trash]\nenable = false\n", encoding="utf-8")
assert load(path).trash_config.enabled is True
path2 = tmp_path / "d.toml"
_ = path2.write_text('[trash]\nenabled = "no"\n', encoding="utf-8")
assert load(path2).trash_config.enabled is True
def test_trash_section_survives_reload(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text("[trash]\nenabled = false\n", encoding="utf-8")
store = load(path)
assert store.trash_config.enabled is False
_ = path.write_text("[trash]\nenabled = true\n", encoding="utf-8")
store.reload()
assert store.trash_config.enabled is True
+88
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
import msgspec
from msgspec import UNSET
@@ -18,10 +19,18 @@ from plyngent.lmproto.openai_compatible.compat import (
from plyngent.lmproto.openai_compatible.model import (
AnyChatMessage,
AssistantChatMessage,
AssistantFunctionTool,
AssistantFunctionToolCall,
ChatCompletionsParam,
ChatImagePart,
ChatImageUrl,
ChatTextPart,
DeveloperChatMessage,
ImageDetail,
SystemChatMessage,
ToolChatMessage,
UserChatMessage,
content_to_text,
)
@@ -119,3 +128,82 @@ def test_coerce_chat_messages_drops_provider_blocks() -> None:
def test_coerce_chat_messages_leaves_plain_history_alone() -> None:
messages: list[AnyChatMessage] = [UserChatMessage(content="hi"), AssistantChatMessage(content="done")]
assert coerce_chat_messages(messages) == messages
def _image(url: str = "data:image/png;base64,AAAA", detail: ImageDetail = "low") -> ChatImagePart:
return ChatImagePart(image_url=ChatImageUrl(url=url, detail=detail))
def _wire(messages: Sequence[AnyChatMessage]) -> list[dict[str, Any]]:
param = ChatCompletionsParam(model="m", messages=coerce_chat_messages(messages))
raw: dict[str, Any] = msgspec.json.decode(msgspec.json.encode(param))
messages_raw: list[dict[str, Any]] = raw["messages"]
return messages_raw
def test_user_images_reach_the_chat_wire_as_blocks() -> None:
messages: list[AnyChatMessage] = [
UserChatMessage(
content=[
ChatTextPart(text="what is this?"),
ChatImagePart(image_url=ChatImageUrl(url="https://example.com/a.png", detail="low")),
]
)
]
assert _wire(messages) == [
{
"role": "user",
"content": [
{"type": "text", "text": "what is this?"},
{"type": "image_url", "image_url": {"url": "https://example.com/a.png", "detail": "low"}},
],
}
]
def test_tool_result_images_move_into_the_user_turn_after_the_tool_run() -> None:
messages: list[AnyChatMessage] = [
AssistantChatMessage(
content="",
tool_calls=[
AssistantFunctionToolCall(
id="call-1", function=AssistantFunctionTool(name="view_image", arguments="{}")
)
],
),
ToolChatMessage(content="image: shot.png (1 KB)", tool_call_id="call-1", images=[_image()]),
ToolChatMessage(content="second result", tool_call_id="call-2"),
AssistantChatMessage(content="I can see it."),
]
raw = _wire(messages)
assert [item["role"] for item in raw] == ["assistant", "tool", "tool", "user", "assistant"]
# The tool message keeps its text and loses the field chat completions cannot send.
assert raw[1] == {"role": "tool", "content": "image: shot.png (1 KB)", "tool_call_id": "call-1"}
assert raw[3]["content"] == [
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA", "detail": "low"}}
]
# The stored history keeps its own image parts.
assert messages[1].images is not UNSET
def test_a_tool_result_without_images_keeps_the_history_untouched() -> None:
messages: list[AnyChatMessage] = [ToolChatMessage(content="plain", tool_call_id="call-1")]
assert coerce_chat_messages(messages) == messages
def test_the_deepseek_param_takes_a_user_turn_with_images() -> None:
base = DeepseekChatCompletionsParam(
model="deepseek-flash",
messages=[UserChatMessage(content=[_image()])],
)
coerced = coerce_chat_completions_param_any(base)
raw = msgspec.json.decode(msgspec.json.encode(coerced))
assert raw["messages"][0]["content"][0]["type"] == "image_url"
def test_content_to_text_replaces_images_with_a_marker() -> None:
assert content_to_text("plain") == "plain"
assert content_to_text([ChatTextPart(text="look:"), _image()]) == "look: [image]"
+135
View File
@@ -12,8 +12,10 @@ from plyngent.prompting import (
NonInteractiveBackend,
NonInteractiveError,
PromptCancelledError,
PromptWait,
_read_line_with_timeout_posix,
ask,
ask_secret,
cancel_prompt_read,
choose,
configure_prompting,
@@ -21,12 +23,15 @@ from plyngent.prompting import (
form,
get_prompt_backend,
get_prompt_output_hook,
get_prompt_wait_hook,
prompt_cancelled,
prompt_wait,
read_line_with_timeout,
reset_prompting,
run_cancellable_prompt_async,
run_prompt_async,
set_prompt_output_hook,
set_prompt_wait_hook,
temporary_backend,
)
@@ -279,3 +284,133 @@ def test_prompt_output_hook_runs_before_chrome(capsys: pytest.CaptureFixture[str
assert calls == ["break", "break"]
assert get_prompt_output_hook() is None
assert "box" in capsys.readouterr().out
class _AnswerSource:
"""Stand-in for a host surface that answers a waiting prompt."""
supports_actions = True
def __init__(self, answer: str | None) -> None:
self.answers = [answer] if answer is not None else []
def take(self) -> str | None:
return self.answers.pop(0) if self.answers else None
def test_read_line_with_timeout_takes_source_answer(capsys: pytest.CaptureFixture[str]) -> None:
"""A host answer ends the read even though stdin never becomes readable."""
stream = io.StringIO()
try:
got = _read_line_with_timeout_posix("p> ", 5.0, stream=stream, poll=_never_poll, source=_AnswerSource("allow"))
finally:
stream.close()
assert got == "allow"
# The answer is echoed on the prompt line, so the terminal is not left mid-line.
assert "allow" in capsys.readouterr().out
def test_read_line_with_timeout_prefers_stdin_over_source() -> None:
"""A human typing at the terminal wins over a pending host answer."""
stream = io.StringIO("typed\n")
try:
got = _read_line_with_timeout_posix("p> ", 5.0, stream=stream, poll=_ready_poll, source=_AnswerSource("allow"))
finally:
stream.close()
assert got == "typed"
def test_read_line_with_timeout_without_deadline() -> None:
"""``timeout=None`` waits until an answer arrives instead of giving up."""
stream = io.StringIO()
try:
got = _read_line_with_timeout_posix("p> ", None, stream=stream, poll=_never_poll, source=_AnswerSource("deny"))
finally:
stream.close()
assert got == "deny"
def test_prompt_wait_without_hook_yields_none() -> None:
"""Without a hook the seam is a no-op (library hosts keep the old behaviour)."""
assert get_prompt_wait_hook() is None
with prompt_wait("confirm", "Allow?") as source:
assert source is None
def test_prompt_wait_hook_wraps_ask() -> None:
"""The CLI hook sees one PromptWait per prompt and is exited afterwards."""
seen: list[PromptWait] = []
entered: list[str] = []
@contextlib.contextmanager
def hook(wait: PromptWait) -> Generator[None]:
seen.append(wait)
entered.append("in")
try:
yield None
finally:
entered.append("out")
set_prompt_wait_hook(hook)
backend = ScriptedBackend(["hi"])
with temporary_backend(backend):
assert ask("Name?") == "hi"
assert [(wait.kind, wait.title) for wait in seen] == [("ask", "Name?")]
assert entered == ["in", "out"]
def test_prompt_wait_hook_reports_the_prompt_family() -> None:
"""Each entry point announces its own kind so a host can pick a surface."""
kinds: list[str] = []
@contextlib.contextmanager
def hook(wait: PromptWait) -> Generator[None]:
kinds.append(wait.kind)
yield None
set_prompt_wait_hook(hook)
backend = ScriptedBackend(["hello"], confirms=[True], secrets=["s3cret"])
with temporary_backend(backend):
_ = ask_secret("Token?")
_ = choose("Pick", ["a", "b"])
_ = confirm("Go?")
assert kinds == ["secret", "choose", "confirm"]
def test_reset_prompting_clears_the_wait_hook() -> None:
set_prompt_wait_hook(lambda wait: contextlib.nullcontext(None))
assert get_prompt_wait_hook() is not None
reset_prompting()
assert get_prompt_wait_hook() is None
def test_ask_takes_an_answer_from_the_notification() -> None:
"""A reply typed into the notification is the answer (no readline involved)."""
from plyngent.prompting import set_prompt_wait_hook
@contextlib.contextmanager
def hook(wait: PromptWait) -> Generator[_AnswerSource]:
del wait
yield _AnswerSource("feature/x")
set_prompt_wait_hook(hook)
backend = ScriptedBackend([]) # any readline would raise
with temporary_backend(backend):
assert ask("Which branch?") == "feature/x"
def test_ask_ignores_a_source_that_offers_no_actions() -> None:
"""A notify-only surface leaves the terminal read alone."""
from plyngent.prompting import set_prompt_wait_hook
@contextlib.contextmanager
def hook(wait: PromptWait) -> Generator[_AnswerSource]:
del wait
source = _AnswerSource("from the notification")
source.supports_actions = False
yield source
set_prompt_wait_hook(hook)
backend = ScriptedBackend(["typed here"])
with temporary_backend(backend):
assert ask("Which branch?") == "typed here"
+18
View File
@@ -11,6 +11,8 @@ import os
import sys
_FAKE_INSTRUCTIONS = os.environ.get("PLYNGENT_MCP_FAKE_INSTRUCTIONS", "")
# A real 1x1 PNG (base64), what an image-carrying tool result looks like.
_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
def respond(request_id: int, result: object) -> None:
@@ -61,6 +63,11 @@ def main() -> None:
"description": "Sleeps past any client timeout.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "shot",
"description": "Returns a picture beside its text.",
"inputSchema": {"type": "object", "properties": {}},
},
]
},
)
@@ -75,6 +82,17 @@ def main() -> None:
"isError": False,
},
)
elif name == "shot":
respond(
int(request_id),
{
"content": [
{"type": "text", "text": "shot.png"},
{"type": "image", "data": _PNG_BASE64, "mimeType": "image/png"},
],
"isError": False,
},
)
elif name == "fail":
respond(
int(request_id),
+21 -6
View File
@@ -34,13 +34,28 @@ async def test_connection_handshake_lists_and_calls_tools() -> None:
assert connection.connected
assert connection.error is None
assert connection.instructions is None
assert [tool.name for tool in connection.tools] == ["echo", "fail", "slow"]
assert [tool.name for tool in connection.tools] == ["echo", "fail", "slow", "shot"]
echo = connection.tools[0]
assert echo.description == "Echo the given text back."
assert echo.input_schema["type"] == "object"
result = await connection.call_tool("echo", {"text": "hello"})
assert result == "echo: hello"
assert result.text == "echo: hello"
assert result.images == []
finally:
await connection.aclose()
async def test_an_image_content_block_becomes_an_image_part() -> None:
"""A server that returns pixels gets them to the model, not a placeholder."""
connection = McpServerConnection("fake", _server_config())
await connection.start()
try:
output = await connection.call_tool("shot", {})
assert output.text == "shot.png"
assert len(output.images) == 1
assert output.images[0].image_url.url.startswith("data:image/png;base64,")
finally:
await connection.aclose()
@@ -64,7 +79,7 @@ async def test_tool_error_returns_error_text() -> None:
await connection.start()
try:
result = await connection.call_tool("fail", {})
assert result == "error: boom"
assert result.text == "error: boom"
finally:
await connection.aclose()
@@ -122,9 +137,9 @@ async def test_manager_start_status_and_call() -> None:
manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[]))
await manager.ensure_started()
try:
assert [(name, status, count) for name, status, count in manager.statuses()] == [("fake", "connected", 3)]
assert [(name, status, count) for name, status, count in manager.statuses()] == [("fake", "connected", 4)]
assert manager.instructions() == []
assert await manager.call("fake", "echo", {"text": "hi"}) == "echo: hi"
assert (await manager.call("fake", "echo", {"text": "hi"})).text == "echo: hi"
finally:
await manager.aclose()
assert manager.statuses() == [("fake", "not started", 0)]
@@ -157,5 +172,5 @@ async def test_manager_disable_and_restart() -> None:
await manager.restart(McpConfig(servers={"fake": _server_config()}, disable=[]))
await manager.ensure_started()
assert manager.statuses() == [("fake", "connected", 3)]
assert manager.statuses() == [("fake", "connected", 4)]
await manager.aclose()
+8 -10
View File
@@ -1,13 +1,10 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from pathlib import Path
from plyngent.config.models import SkillsConfig
from plyngent.skills import SKILL_FILE, Skill, SkillStore, build_roots, validate_skill_name
if TYPE_CHECKING:
from pathlib import Path
def _write_skill(root: Path, name: str, *, description: str | None = "A test skill", body: str = "Body.") -> Path:
directory = root / name
@@ -28,12 +25,13 @@ def test_build_roots_orders_explicit_paths_first(tmp_path: Path) -> None:
user_config_dir=tmp_path / "config",
home=tmp_path / "home",
)
assert [(r.origin, r.scope, str(r.path)) for r in roots] == [
("config", "user", str(tmp_path / "home" / "team-skills")),
("config", "user", "/srv/skills"),
("plyngent", "user", str(tmp_path / "config" / "skills")),
("claude", "user", str(tmp_path / "home" / ".claude" / "skills")),
("claude", "project", str(tmp_path / "proj" / ".claude" / "skills")),
# Paths, not strings: a Windows run spells the same root differently.
assert [(r.origin, r.scope, r.path) for r in roots] == [
("config", "user", tmp_path / "home" / "team-skills"),
("config", "user", Path("/srv/skills")),
("plyngent", "user", tmp_path / "config" / "skills"),
("claude", "user", tmp_path / "home" / ".claude" / "skills"),
("claude", "project", tmp_path / "proj" / ".claude" / "skills"),
]
+1
View File
@@ -59,6 +59,7 @@ def test_read_only_classification() -> None:
read_only = {t.name for t in default_tool_definitions(surface="local") if t.tags & ToolTag.READ_ONLY}
assert read_only == {
"read_file",
"view_image",
"get_truncated",
"glob_paths",
"regex_files",
+10 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from plyngent.tools.command_scan import basename, is_interpreter, unwrap_command
from plyngent.tools.command_scan import basename, escalators_in_chain, is_interpreter, unwrap_command
def test_basename() -> None:
@@ -9,6 +9,15 @@ def test_basename() -> None:
assert basename("pdm") == "pdm"
def test_escalators_in_chain() -> None:
assert escalators_in_chain(["sudo", "-u", "root", "python", "-c", "print(1)"]) == ("sudo",)
assert escalators_in_chain(["env", "FOO=1", "doas", "id"]) == ("doas",)
assert escalators_in_chain(["pdm", "run", "sudo", "id"]) == ("sudo",)
# Only the command line counts: the code an interpreter carries is not read.
assert escalators_in_chain(["bash", "-c", "sudo id"]) == ()
assert escalators_in_chain(["grep", "-c", "sudo", "notes.txt"]) == ()
def test_unwrap_plain_command() -> None:
scan = unwrap_command(["grep", "-c", "needle", "f.txt"])
assert scan.wrappers == ()
+48 -8
View File
@@ -2,18 +2,18 @@
from __future__ import annotations
from plyngent.agent import ToolRegistry, ToolTag, tool
from plyngent.agent import DangerReason, ToolRegistry, ToolTag, tool
from plyngent.tools.context import SessionState, bind_tool_context
from plyngent.tools.grants import clear_grants, has_grant, hydrate_grants, persist_grants
from plyngent.tools.view import MemoryViewStore, session_data_view
async def test_danger_overrides_yolo_tag() -> None:
"""Decorator YOLO tag does not bypass confirm when danger flags the call."""
async def test_yolo_only_skips_yolo_tagged_tools() -> None:
"""YOLO mode auto-approves the soft confirms of YOLO-tagged tools only."""
calls: list[str] = []
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
async def yolo_tool() -> str:
async def yolo_ok() -> str:
return "y"
@tool(tags=ToolTag.LOCAL, register=False)
@@ -28,15 +28,55 @@ async def test_danger_overrides_yolo_tag() -> None:
return True
reg = ToolRegistry(
[yolo_tool, no_yolo],
[yolo_ok, no_yolo],
danger=danger,
on_confirm=confirm,
yolo=True,
)
assert await reg.execute("yolo_tool", "{}") == "y"
assert calls == ["yolo_tool"]
assert await reg.execute("yolo_ok", "{}") == "y"
assert calls == []
assert await reg.execute("no_yolo", "{}") == "n"
assert calls == ["yolo_tool", "no_yolo"]
assert calls == ["no_yolo"]
async def test_yolo_off_keeps_the_yolo_tagged_prompt() -> None:
"""The YOLO tag is a policy bit: with YOLO off the call still asks."""
calls: list[str] = []
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
async def yolo_ok() -> str:
return "y"
def danger(_name: str, _args: object) -> str | None:
return "soft reason"
async def confirm(name: str, _args: object, _reason: str) -> bool:
calls.append(name)
return True
reg = ToolRegistry([yolo_ok], danger=danger, on_confirm=confirm, yolo=False)
assert await reg.execute("yolo_ok", "{}") == "y"
assert calls == ["yolo_ok"]
async def test_yolo_keeps_a_reason_that_refuses_the_skip() -> None:
"""`DangerReason(yolo_skip=False)` — a privilege escalation — still prompts."""
seen: list[str] = []
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
async def escalated() -> str:
return "x"
def danger(_name: str, _args: object) -> DangerReason:
return DangerReason("interpreter 'python' + privilege escalation sudo", yolo_skip=False)
async def confirm(_name: str, _args: object, reason: str) -> bool:
seen.append(reason)
return True
reg = ToolRegistry([escalated], danger=danger, on_confirm=confirm, yolo=True)
assert await reg.execute("escalated", "{}") == "x"
assert seen == ["interpreter 'python' + privilege escalation sudo"]
async def test_trustable_grant_once() -> None:
+107 -45
View File
@@ -2,19 +2,53 @@ from __future__ import annotations
from pathlib import Path
from plyngent.agent import DangerReason
from plyngent.tools.danger import classify_danger
from plyngent.tools.workspace import set_trash_enabled
def _reason(name: str, args: dict[str, object]) -> str | None:
"""Confirm text of a classifier answer (``None`` when the call needs none)."""
found = classify_danger(name, args)
if found is None:
return None
return found if isinstance(found, str) else found.text
def _danger(name: str, args: dict[str, object]) -> DangerReason:
"""Classifier answer as a typed reason (the call must need a confirm)."""
found = classify_danger(name, args)
assert isinstance(found, DangerReason), found
return found
def test_classify_delete_and_move() -> None:
assert classify_danger("delete_path", {"path": "a.txt"}) == "delete path 'a.txt'"
assert "recursively" in (classify_danger("delete_path", {"path": "d", "recursive": True}) or "")
assert "move" in (classify_danger("move_path", {"src": "a", "dst": "b"}) or "")
# No bound workspace here, so the confirm falls back to the default wording.
assert _reason("delete_path", {"path": "a.txt"}) == "move path 'a.txt' to the system trash"
assert "recursively" in (_reason("delete_path", {"path": "d", "recursive": True}) or "")
assert "move" in (_reason("move_path", {"src": "a", "dst": "b"}) or "")
def test_classify_permanent_delete() -> None:
assert _reason("delete_path", {"path": "a.txt", "permanent": True}) == "delete path 'a.txt' permanently"
def test_a_scratch_path_is_announced_as_permanent(workspace: object) -> None:
"""The confirm says what will happen: the temp workspace is never trashed."""
del workspace
assert _reason("delete_path", {"path": "a.txt"}) == "delete path 'a.txt' permanently"
def test_the_host_switch_is_announced_as_permanent(workspace: object) -> None:
del workspace
set_trash_enabled(enabled=False)
assert _reason("delete_path", {"path": "a.txt"}) == "delete path 'a.txt' permanently"
def test_classify_copy() -> None:
# Without overwrite flag, copy is not soft-confirmed.
assert classify_danger("copy_path", {"src": "a", "dst": "b"}) is None
assert classify_danger("copy_path", {"src": "a", "dst": "b", "overwrite": False}) is None
assert _reason("copy_path", {"src": "a", "dst": "b"}) is None
assert _reason("copy_path", {"src": "a", "dst": "b", "overwrite": False}) is None
def test_classify_write_overwrite_only(tmp_path: Path) -> None:
@@ -25,35 +59,35 @@ def test_classify_write_overwrite_only(tmp_path: Path) -> None:
with bind_instance(instance):
_ = set_workspace_root(tmp_path)
# New file: no soft-confirm, with or without the overwrite flag
assert classify_danger("write_file", {"path": "new.txt"}) is None
assert classify_danger("write_file", {"path": "new.txt", "overwrite": True}) is None
assert _reason("write_file", {"path": "new.txt"}) is None
assert _reason("write_file", {"path": "new.txt", "overwrite": True}) is None
# Partial edits: never soft-confirm
assert classify_danger("edit_replace", {"path": "x.txt"}) is None
assert classify_danger("edit_lineno", {"path": "x.txt", "start_line": 1, "end_line": 2}) is None
assert _reason("edit_replace", {"path": "x.txt"}) is None
assert _reason("edit_lineno", {"path": "x.txt", "start_line": 1, "end_line": 2}) is None
# Existing file: only an explicit overwrite asks; an unset/false flag is
# refused inside the tool, so no prompt may fire for it.
(tmp_path / "x.txt").write_text("old", encoding="utf-8")
assert classify_danger("write_file", {"path": "x.txt"}) is None
assert classify_danger("write_file", {"path": "x.txt", "overwrite": False}) is None
reason = classify_danger("write_file", {"path": "x.txt", "overwrite": True})
assert _reason("write_file", {"path": "x.txt"}) is None
assert _reason("write_file", {"path": "x.txt", "overwrite": False}) is None
reason = _reason("write_file", {"path": "x.txt", "overwrite": True})
assert reason is not None and "overwrite" in reason
(tmp_path / "dst.txt").write_text("d", encoding="utf-8")
(tmp_path / "src.txt").write_text("s", encoding="utf-8")
assert classify_danger("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": False}) is None
creason = classify_danger("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": True})
assert _reason("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": False}) is None
creason = _reason("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": True})
assert creason is not None and "overwrite" in creason
def test_classify_safe_tools() -> None:
assert classify_danger("read_file", {"path": "a"}) is None
assert classify_danger("listdir", {"path": "."}) is None
assert classify_danger("run_argv", {"argv": ["echo", "hi"]}) is None
assert classify_danger("open_pty", {"command": ["true"]}) is None
assert classify_danger("run_argv", {"argv": ["ls", "-la"]}) is None
assert _reason("read_file", {"path": "a"}) is None
assert _reason("listdir", {"path": "."}) is None
assert _reason("run_argv", {"argv": ["echo", "hi"]}) is None
assert _reason("open_pty", {"command": ["true"]}) is None
assert _reason("run_argv", {"argv": ["ls", "-la"]}) is None
def test_classify_run_argv_batch_risky() -> None:
reason = classify_danger(
reason = _reason(
"run_argv_batch",
{
"steps": [
@@ -66,7 +100,7 @@ def test_classify_run_argv_batch_risky() -> None:
assert "run_argv_batch" in reason
assert "risky" in reason or "bash" in reason
assert (
classify_danger(
_reason(
"run_argv_batch",
{"steps": [{"argv": ["echo", "ok"]}]},
)
@@ -75,42 +109,42 @@ def test_classify_run_argv_batch_risky() -> None:
def test_classify_shell_and_dash_c() -> None:
r = classify_danger("run_argv", {"argv": ["bash", "-c", "rm -rf /"]})
r = _reason("run_argv", {"argv": ["bash", "-c", "rm -rf /"]})
assert r is not None
assert "bash -c" in r
assert "rm -rf" in r
r2 = classify_danger("run_argv", {"argv": ["python3", "-c", "print(1)"]})
r2 = _reason("run_argv", {"argv": ["python3", "-c", "print(1)"]})
assert r2 is not None
assert "python" in r2
assert "-c" in r2
assert "print(1)" in r2
r_py = classify_danger("run_argv", {"argv": ["python", "-c", "import os"]})
r_py = _reason("run_argv", {"argv": ["python", "-c", "import os"]})
assert r_py is not None and "python -c" in r_py
r3 = classify_danger("open_pty", {"command": ["bash"]})
r3 = _reason("open_pty", {"command": ["bash"]})
assert r3 is not None and "bash" in r3
assert "interactive" in r3 or "review" in r3
r4 = classify_danger("open_pty", {"command": ["python3"]})
r4 = _reason("open_pty", {"command": ["python3"]})
assert r4 is not None and "python" in r4
r5 = classify_danger("open_pty", {"command": ["python3", "-c", "x=1"]})
r5 = _reason("open_pty", {"command": ["python3", "-c", "x=1"]})
assert r5 is not None and "-c" in r5
def test_dash_c_means_code_only_for_interpreters() -> None:
# ``-c`` is a plain flag elsewhere: no code to review, no confirm.
assert classify_danger("run_argv", {"argv": ["grep", "-c", "needle", "f.txt"]}) is None
assert classify_danger("run_argv", {"argv": ["od", "-c", "f.bin"]}) is None
assert classify_danger("run_argv", {"argv": ["sort", "-c", "f.txt"]}) is None
assert _reason("run_argv", {"argv": ["grep", "-c", "needle", "f.txt"]}) is None
assert _reason("run_argv", {"argv": ["od", "-c", "f.bin"]}) is None
assert _reason("run_argv", {"argv": ["sort", "-c", "f.txt"]}) is None
def test_interpreter_review_does_not_split_on_dash_c() -> None:
script = classify_danger("run_argv", {"argv": ["python", "script.py"]})
one_liner = classify_danger("run_argv", {"argv": ["python", "-c", "print(1)"]})
bare_shell = classify_danger("open_pty", {"command": ["bash"]})
script = _reason("run_argv", {"argv": ["python", "script.py"]})
one_liner = _reason("run_argv", {"argv": ["python", "-c", "print(1)"]})
bare_shell = _reason("open_pty", {"command": ["bash"]})
assert script is not None and "interpreter 'python'" in script
assert one_liner is not None and "interpreter 'python'" in one_liner
assert bare_shell is not None and "interpreter 'bash'" in bare_shell
@@ -121,7 +155,7 @@ def test_interpreter_review_does_not_split_on_dash_c() -> None:
def test_versioned_interpreter_is_still_reviewed() -> None:
reason = classify_danger("run_argv", {"argv": ["python3.12", "-c", "import os"]})
reason = _reason("run_argv", {"argv": ["python3.12", "-c", "import os"]})
assert reason is not None and "interpreter 'python3.12'" in reason
assert "import os" in reason
@@ -134,7 +168,7 @@ def test_wrappers_reach_the_interpreter() -> None:
["pdm", "run", "python", "-c", "print(1)"],
["uv", "run", "--with", "rich", "python", "-c", "print(1)"],
):
reason = classify_danger("run_argv", {"argv": argv})
reason = _reason("run_argv", {"argv": argv})
assert reason is not None, argv
assert "interpreter 'python'" in reason or "interpreter 'bash'" in reason
assert "$(command)" in reason
@@ -142,7 +176,7 @@ def test_wrappers_reach_the_interpreter() -> None:
def test_value_free_flags_do_not_hide_the_interpreter() -> None:
"""``exec -c`` takes no value, so the interpreter behind it is still reviewed."""
reason = classify_danger("run_argv", {"argv": ["exec", "-c", "python", "-c", "print(1)"]})
reason = _reason("run_argv", {"argv": ["exec", "-c", "python", "-c", "print(1)"]})
assert reason is not None
assert "interpreter 'python'" in reason
assert "print(1)" in reason
@@ -150,26 +184,26 @@ def test_value_free_flags_do_not_hide_the_interpreter() -> None:
def test_wrapper_command_line_option_is_reviewed() -> None:
"""`env -S` / `su -c` carry a command line, so they are confirmed like `-c`."""
reason = classify_danger("run_argv", {"argv": ["env", "-S", "rm -rf /"]})
reason = _reason("run_argv", {"argv": ["env", "-S", "rm -rf /"]})
assert reason is not None
assert "env -S runs a command line" in reason
assert " env -S:\n rm -rf /" in reason
# Plain wrapper options are not a review trigger.
assert classify_danger("run_argv", {"argv": ["env", "-u", "FOO=1", "git", "status"]}) is None
assert classify_danger("run_argv", {"argv": ["env", "FOO=1", "git", "-c", "x=y", "log"]}) is None
assert _reason("run_argv", {"argv": ["env", "-u", "FOO=1", "git", "status"]}) is None
assert _reason("run_argv", {"argv": ["env", "FOO=1", "git", "-c", "x=y", "log"]}) is None
def test_wrapper_command_line_value_needs_no_interpreter() -> None:
"""The wrapper's value is reviewed even when no interpreter is resolved."""
reason = classify_danger("run_argv", {"argv": ["env", "-S", "python -O script.py"]})
reason = _reason("run_argv", {"argv": ["env", "-S", "python -O script.py"]})
assert reason is not None
assert "env -S runs a command line" in reason
assert "python -O script.py" in reason
def test_only_the_interpreter_dash_c_becomes_a_placeholder() -> None:
reason = classify_danger("run_argv", {"argv": ["ionice", "-c", "2", "python", "-c", "print(1)"]})
reason = _reason("run_argv", {"argv": ["ionice", "-c", "2", "python", "-c", "print(1)"]})
assert reason is not None
argv_line = next(line for line in reason.splitlines() if line.startswith(" argv:"))
assert "ionice -c 2" in argv_line
@@ -178,15 +212,43 @@ def test_only_the_interpreter_dash_c_becomes_a_placeholder() -> None:
def test_detached_runner_is_always_reviewed() -> None:
assert classify_danger("run_argv", {"argv": ["sleep", "5"]}) is None
reason = classify_danger("run_argv", {"argv": ["nohup", "sleep", "5"]})
assert _reason("run_argv", {"argv": ["sleep", "5"]}) is None
reason = _reason("run_argv", {"argv": ["nohup", "sleep", "5"]})
assert reason is not None and "nohup (detached run)" in reason
nested = classify_danger("run_argv", {"argv": ["nohup", "python", "-c", "print(1)"]})
nested = _reason("run_argv", {"argv": ["nohup", "python", "-c", "print(1)"]})
assert nested is not None
assert "nohup (detached run)" in nested and "interpreter 'python'" in nested
assert "print(1)" in nested
def test_privilege_escalation_refuses_the_yolo_skip() -> None:
"""A reason that runs through `sudo`/`doas` keeps its prompt under YOLO mode."""
reason = _danger("run_argv", {"argv": ["sudo", "python", "-c", "print(1)"]})
assert "interpreter 'python'" in reason.text
assert "privilege escalation sudo" in reason.text
assert reason.yolo_skip is False
assert _danger("run_argv", {"argv": ["env", "FOO=1", "doas", "bash", "-c", "ls"]}).yolo_skip is False
# A plain interpreter run is still YOLO mode's to drop.
assert _danger("run_argv", {"argv": ["bash", "-c", "echo hi"]}).yolo_skip is True
# Escalation adds no reason of its own: the denylist gate asks about it.
assert classify_danger("run_argv", {"argv": ["sudo", "ls"]}) is None
def test_batch_refuses_the_yolo_skip_when_a_step_escalates() -> None:
"""One escalating step keeps the batch prompt; all-plain steps may be dropped."""
mixed = _danger(
"run_argv_batch",
{"steps": [{"argv": ["bash", "-c", "ls"]}, {"argv": ["sudo", "python", "-c", "print(1)"]}]},
)
assert mixed.yolo_skip is False
plain = _danger(
"run_argv_batch",
{"steps": [{"argv": ["bash", "-c", "ls"]}, {"argv": ["python", "-c", "print(1)"]}]},
)
assert plain.yolo_skip is True
async def test_confirm_deny_with_comment() -> None:
from plyngent.agent.tools import ToolRegistry, tool
from plyngent.tools.danger import classify_danger as danger
@@ -208,7 +270,7 @@ async def test_confirm_deny_with_comment() -> None:
def test_shell_confirm_formats_command_placeholder() -> None:
script = chr(10).join(["line1", "line2", "line3"])
reason = classify_danger(
reason = _reason(
"run_argv",
{"argv": ["bash", "-c", script]},
)
+27 -2
View File
@@ -2,12 +2,14 @@
from __future__ import annotations
import base64
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import TYPE_CHECKING, override
import pytest
from plyngent.agent import ToolOutput
from plyngent.tools.context import read_only_context
from plyngent.tools.danger import classify_danger
from plyngent.tools.net import fetch_local, grant_private_host
@@ -29,6 +31,12 @@ if TYPE_CHECKING:
from pathlib import Path
# A real 1x1 PNG: the body must be sniffed by its bytes, not by the header.
_PNG_BYTES = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
class _Handler(BaseHTTPRequestHandler):
"""Minimal echo server for fetch_local tests."""
@@ -66,6 +74,9 @@ class _Handler(BaseHTTPRequestHandler):
if self.path.startswith("/bin"):
self._send(200, b"\x00\x01\x02\xffbinary", content_type="application/octet-stream")
return
if self.path.startswith("/png"):
self._send(200, _PNG_BYTES, content_type="image/png")
return
if self.path.startswith("/ua"):
ua = (self.headers.get("User-Agent") or "").encode()
self._send(200, ua)
@@ -173,9 +184,9 @@ def test_soft_confirm_reason_matrix() -> None:
def test_classify_danger_fetch() -> None:
assert classify_danger("fetch_local", {"url": "https://example.com/", "method": "GET"}) is None
reason = classify_danger("fetch_local", {"url": "http://example.com/", "method": "GET"})
assert reason is not None and "cleartext" in reason
assert isinstance(reason, str) and "cleartext" in reason
reason2 = classify_danger("fetch_local", {"url": "https://example.com/api", "method": "DELETE"})
assert reason2 is not None and "DELETE" in reason2
assert isinstance(reason2, str) and "DELETE" in reason2
async def test_fetch_get_post_put_delete(workspace: Path, http_server: str) -> None:
@@ -200,6 +211,20 @@ async def test_fetch_get_post_put_delete(workspace: Path, http_server: str) -> N
assert "status=204" in out_del
async def test_an_image_response_comes_back_as_the_picture(workspace: Path, http_server: str) -> None:
"""A PNG body is handed to the model as an image part, not a hex preview."""
del workspace
grant_private_host("127.0.0.1", int(http_server.rsplit(":", 1)[1]))
output: object = await fetch_local.handler(f"{http_server}/png")
assert isinstance(output, ToolOutput)
assert "body_kind=image" in output.text
assert "status=200" in output.text
part = output.images[0]
assert part.image_url.url.startswith("data:image/png;base64,")
async def test_fetch_read_only_context_blocks_non_get(workspace: Path, http_server: str) -> None:
"""In a read-only context fetch_local only allows GET; outside it POST works."""
del workspace
+87 -1
View File
@@ -2,7 +2,10 @@ from __future__ import annotations
from pathlib import Path
from plyngent.tools.file import copy_path, delete_path, move_path, read_file, write_file
import pytest
from plyngent.tools.file import copy_path, delete_path, fs_ops, move_path, read_file, write_file
from plyngent.tools.workspace import set_trash_enabled
from tests.test_tools.helpers import call_sync
@@ -75,6 +78,8 @@ def test_delete_nonempty_requires_recursive(workspace: object) -> None:
out = call_sync(delete_path, "box")
assert "not empty" in out
assert (workspace / "box" / "a.txt").is_file()
# The workspace fixture lives under the system temp dir, so it is removed
# for good rather than trashed (see the trash tests below).
out2 = call_sync(delete_path, "box", recursive=True)
assert "recursive" in out2
assert not (workspace / "box").exists()
@@ -97,3 +102,84 @@ def test_missing_source(workspace: object) -> None:
assert "does not exist" in call_sync(copy_path, "nope", "x")
assert "does not exist" in call_sync(move_path, "nope", "x")
assert "does not exist" in call_sync(delete_path, "nope")
def _elsewhere_than_temp(monkeypatch: pytest.MonkeyPatch) -> None:
"""Pretend the workspace is not scratch space, so deletes use the trash."""
monkeypatch.setattr(fs_ops, "under_system_temp", lambda _path: False)
def test_a_temp_path_is_deleted_for_good(workspace: object) -> None:
assert isinstance(workspace, Path)
_ = call_sync(write_file, "t.txt", "x")
out = call_sync(delete_path, "t.txt")
assert "trash" not in out
assert not (workspace / "t.txt").exists()
def test_delete_goes_to_the_trash(workspace: object, monkeypatch: pytest.MonkeyPatch) -> None:
assert isinstance(workspace, Path)
_elsewhere_than_temp(monkeypatch)
trashed: list[Path] = []
monkeypatch.setattr(fs_ops, "move_to_trash", trashed.append)
_ = call_sync(write_file, "t.txt", "x")
out = call_sync(delete_path, "t.txt")
assert out == "deleted file t.txt (moved to system trash)"
assert [path.name for path in trashed] == ["t.txt"]
def test_recursive_delete_goes_to_the_trash(workspace: object, monkeypatch: pytest.MonkeyPatch) -> None:
assert isinstance(workspace, Path)
_elsewhere_than_temp(monkeypatch)
trashed: list[Path] = []
monkeypatch.setattr(fs_ops, "move_to_trash", trashed.append)
_ = call_sync(write_file, "box/a.txt", "a")
out = call_sync(delete_path, "box", recursive=True)
assert out == "deleted directory box (moved to system trash)"
assert [path.name for path in trashed] == ["box"]
def test_a_refused_trash_keeps_the_path(workspace: object, monkeypatch: pytest.MonkeyPatch) -> None:
assert isinstance(workspace, Path)
_elsewhere_than_temp(monkeypatch)
refused = "error: cannot move to trash: nope (use permanent=true)"
monkeypatch.setattr(fs_ops, "move_to_trash", lambda _target: refused)
_ = call_sync(write_file, "t.txt", "x")
out = call_sync(delete_path, "t.txt")
assert out == refused
assert (workspace / "t.txt").is_file()
def test_permanent_delete_skips_the_trash(workspace: object, monkeypatch: pytest.MonkeyPatch) -> None:
assert isinstance(workspace, Path)
def _never(_target: Path) -> str | None:
msg = "a permanent delete must not touch the trash"
raise AssertionError(msg)
monkeypatch.setattr(fs_ops, "move_to_trash", _never)
_ = call_sync(write_file, "t.txt", "x")
out = call_sync(delete_path, "t.txt", permanent=True)
assert out == "deleted file t.txt"
assert not (workspace / "t.txt").exists()
def test_the_host_switch_turns_the_trash_off(workspace: object, monkeypatch: pytest.MonkeyPatch) -> None:
"""``[trash] enabled = false`` restores the behaviour from before the trash."""
assert isinstance(workspace, Path)
_elsewhere_than_temp(monkeypatch)
set_trash_enabled(enabled=False)
_ = call_sync(write_file, "t.txt", "x")
out = call_sync(delete_path, "t.txt")
assert out == "deleted file t.txt"
assert not (workspace / "t.txt").exists()
+130
View File
@@ -0,0 +1,130 @@
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
import send2trash
from plyngent.tools import trash
from plyngent.tools.trash import is_available, move_to_trash, under_system_temp
# The freedesktop implementation is the only one whose trash location can be
# redirected for a test; macOS and Windows move into their own system trash.
freedesktop_only = pytest.mark.skipif(sys.platform != "linux", reason="freedesktop trash only")
@pytest.fixture
def home_trash(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point the freedesktop implementation at a trash under *tmp_path*.
``plat_other`` reads its trash locations from module globals computed at
import time and picks "home" vs "external volume" by device number, so both
are pinned here — otherwise a ``/tmp`` on a filesystem of its own would
create a real trash directory on that volume.
"""
plat_other = pytest.importorskip("send2trash.plat_other")
root = tmp_path / "Trash"
monkeypatch.setattr(plat_other, "XDG_DATA_HOME", os.fsencode(str(tmp_path)))
monkeypatch.setattr(plat_other, "HOMETRASH_B", os.fsencode(str(root)))
monkeypatch.setattr(plat_other, "HOMETRASH", str(root))
monkeypatch.setattr(plat_other, "get_dev", lambda _path: 1)
# A GIO-backed install would otherwise bypass every patch above.
monkeypatch.setattr(send2trash, "send2trash", plat_other.send2trash)
return root
def test_the_library_is_installed() -> None:
assert is_available()
@freedesktop_only
def test_move_to_trash_lands_in_the_trash(home_trash: Path, tmp_path: Path) -> None:
target = tmp_path / "gone.txt"
_ = target.write_text("x")
assert move_to_trash(target) is None
assert not target.exists()
assert (home_trash / "files" / "gone.txt").read_text() == "x"
entry = (home_trash / "info" / "gone.txt.trashinfo").read_text()
assert entry.startswith("[Trash Info]")
assert "Path=" in entry
assert "DeletionDate=" in entry
@freedesktop_only
def test_move_to_trash_moves_a_directory_in_one_piece(home_trash: Path, tmp_path: Path) -> None:
target = tmp_path / "box"
target.mkdir()
_ = (target / "inner.txt").write_text("x")
assert move_to_trash(target) is None
assert not target.exists()
assert (home_trash / "files" / "box" / "inner.txt").read_text() == "x"
@freedesktop_only
def test_trashing_twice_keeps_both_copies(home_trash: Path, tmp_path: Path) -> None:
first = tmp_path / "dup.txt"
second = tmp_path / "sub" / "dup.txt"
second.parent.mkdir()
_ = first.write_text("one")
_ = second.write_text("two")
assert move_to_trash(first) is None
assert move_to_trash(second) is None
names = sorted(path.name for path in (home_trash / "files").iterdir())
assert names == ["dup 1.txt", "dup.txt"]
def test_a_refused_volume_names_the_escape_hatch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
target = tmp_path / "gone.txt"
_ = target.write_text("x")
def _refuse(_paths: object) -> None:
raise send2trash.TrashPermissionError(str(target))
monkeypatch.setattr(send2trash, "send2trash", _refuse)
error = move_to_trash(target)
assert error is not None
assert error.startswith("error: cannot move to trash:")
assert "permanent=true" in error
assert target.is_file()
def test_an_oserror_is_reported(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
target = tmp_path / "gone.txt"
_ = target.write_text("x")
def _fail(_paths: object) -> None:
raise OSError("disk on fire")
monkeypatch.setattr(send2trash, "send2trash", _fail)
assert move_to_trash(target) == "error: cannot move to trash: disk on fire"
assert target.is_file()
def test_a_broken_install_leaves_the_file_alone(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
target = tmp_path / "gone.txt"
_ = target.write_text("x")
monkeypatch.setattr(trash, "is_available", lambda: False)
error = move_to_trash(target)
assert error is not None
assert "send2trash is not installed" in error
assert "permanent=true" in error
assert target.is_file()
def test_under_system_temp(tmp_path: Path) -> None:
assert under_system_temp(tmp_path)
assert under_system_temp(tmp_path / "nested" / "file.txt")
assert not under_system_temp(Path("/plyngent-not-temp/deep/file.txt"))
+68
View File
@@ -0,0 +1,68 @@
"""``view_image``: a local picture reaches the model as a picture."""
from __future__ import annotations
import base64
from typing import TYPE_CHECKING
from plyngent.agent import ToolOutput
from plyngent.tools.file.read import read_file
from plyngent.tools.file.view_image import view_image
if TYPE_CHECKING:
from pathlib import Path
# A real 1x1 PNG: the format is sniffed from these bytes.
PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
async def test_view_image_attaches_the_picture(workspace: Path) -> None:
(workspace / "shot.png").write_bytes(PNG)
output = await view_image.handler("shot.png")
assert isinstance(output, ToolOutput)
assert output.text == "image: shot.png (attached below)"
url = output.images[0].image_url.url
assert url.startswith("data:image/png;base64,")
assert base64.b64decode(url.partition(",")[2]) == PNG
async def test_the_detail_level_reaches_the_part(workspace: Path) -> None:
(workspace / "shot.png").write_bytes(PNG)
output = await view_image.handler("shot.png", detail="low")
assert isinstance(output, ToolOutput)
assert output.images[0].image_url.detail == "low"
async def test_an_unknown_detail_level_is_an_error(workspace: Path) -> None:
(workspace / "shot.png").write_bytes(PNG)
assert "detail must be one of" in await view_image.handler("shot.png", detail="huge")
async def test_a_non_image_file_is_an_error(workspace: Path) -> None:
(workspace / "notes.txt").write_text("hello")
result = await view_image.handler("notes.txt")
assert isinstance(result, str)
assert "not a JPEG, PNG, GIF, or WebP image" in result
async def test_a_missing_file_is_an_error(workspace: Path) -> None:
del workspace
assert await view_image.handler("nope.png") == "error: file not found: nope.png"
async def test_read_file_points_at_view_image_for_a_picture(workspace: Path) -> None:
(workspace / "shot.png").write_bytes(PNG)
result = await read_file.handler("shot.png")
assert result == "error: shot.png is an image (image/png); use view_image to look at it"
+12
View File
@@ -7,12 +7,14 @@ from plyngent.tools import (
WorkspaceError,
active_workspace_policy,
check_command_allowed,
get_trash_enabled,
get_workspace_root,
mode_covers,
parse_access_mode,
resolve_path,
set_command_denylist,
set_path_denylist,
set_trash_enabled,
)
from plyngent.tools.context import SessionState, bind_session
@@ -55,6 +57,16 @@ def test_access_mode_parse_and_covers() -> None:
assert not mode_covers(AccessMode.READ, AccessMode.WRITE)
def test_trash_switch(workspace: object) -> None:
"""Deletes go to the trash until a host clears the switch."""
del workspace
assert get_trash_enabled() is True
set_trash_enabled(enabled=False)
assert get_trash_enabled() is False
set_trash_enabled(enabled=True)
assert get_trash_enabled() is True
def test_config_grant_modes(workspace: object, tmp_path_factory: pytest.TempPathFactory) -> None:
del workspace
outside = tmp_path_factory.mktemp("granted")
+66
View File
@@ -0,0 +1,66 @@
# Minimal stubs for the optional ``desktop-notifier`` extra.
# Only the surface ``plyngent.cli.notify`` uses is declared; the real package is
# installed with ``pip install plyngent[notify]`` and is absent by default.
from collections.abc import Sequence
from enum import Enum
from typing import Any
class Capability(Enum):
APP_NAME = ...
TITLE = ...
MESSAGE = ...
URGENCY = ...
ICON = ...
BUTTONS = ...
REPLY_FIELD = ...
ATTACHMENT = ...
ON_DISPATCHED = ...
ON_CLICKED = ...
ON_DISMISSED = ...
SOUND = ...
THREAD = ...
TIMEOUT = ...
class Button:
def __init__(
self,
title: str,
on_pressed: Any = None,
identifier: str | None = None,
) -> None: ...
class ReplyField:
def __init__(
self,
title: str = ...,
button_title: str = ...,
on_replied: Any = None,
) -> None: ...
class DesktopNotifier:
def __init__(
self,
app_name: str = ...,
app_icon: Any = ...,
notification_limit: int | None = None,
) -> None: ...
async def get_capabilities(self) -> frozenset[Capability]: ...
async def send(
self,
title: str,
message: str,
urgency: Any = ...,
icon: Any = ...,
buttons: Sequence[Button] = ...,
reply_field: ReplyField | None = None,
on_dispatched: Any = None,
on_clicked: Any = None,
on_dismissed: Any = None,
attachment: Any = ...,
sound: Any = ...,
thread: str | None = None,
timeout: int = -1, # noqa: ASYNC109 - the library's own parameter name
) -> str: ...
async def clear(self, identifier: str) -> None: ...
async def clear_all(self) -> None: ...
+9
View File
@@ -0,0 +1,9 @@
# Minimal stubs for ``send2trash`` (a required dependency).
# Only the surface ``plyngent.tools.trash`` uses is declared; the real package is
# pure Python over ctypes (freedesktop trash spec / Finder trash / Recycle Bin).
from collections.abc import Iterable
class TrashPermissionError(PermissionError): ...
def send2trash(paths: str | Iterable[str]) -> None: ...