mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
Compare commits
42 Commits
v0.14.0
...
9a7f7c9c8f
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a7f7c9c8f | |||
| 0b144caba0 | |||
| 0a700ec8b1 | |||
| 81d443cf40 | |||
| 770e08e56e | |||
| 0d71923472 | |||
| 9027901168 | |||
| 809715bff6 | |||
| 2729f12d26 | |||
| 5c13d10989 | |||
| b897bac52b | |||
| 725caaada4 | |||
| dd678d213b | |||
| 4bab780603 | |||
| 8cbb98602e | |||
| 4c67f6bb04 | |||
| 79ccf593f6 | |||
| 329fec8b3e | |||
| 2614f8ca99 | |||
| 7b3f55ea65 | |||
| b75ee88764 | |||
| 6d7c239718 | |||
| ed8013d6ce | |||
| 05166f2f76 | |||
| f9ec812275 | |||
| 1c2587db82 | |||
| 828c2610cb | |||
| 7abc7c2cb8 | |||
| 7d29498dfa | |||
| 140268f983 | |||
| 7d6e367143 | |||
| 63109ef9f2 | |||
| dfaaad563c | |||
| 4676b9af32 | |||
| 865d314234 | |||
| 9139655e54 | |||
| 741d1a8526 | |||
| 44b6ab90ed | |||
| fb77fd676b | |||
| a79039f06f | |||
| ff656b800a | |||
| 2039d4f31f |
@@ -85,33 +85,33 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
||||
- **`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, and every image-carrying path checks it — an `@path` mention, `view_image`, `fetch_local`'s image body, and (as a safety net in the registry) an MCP server's or plugin's `ToolOutput`.
|
||||
- **Provider-side tools**: `provider_tools` on the OpenAI **and** DeepSeek presets (list of dicts; `[{type="web_search"}]` when omitted; `[]` disables) threaded via `ChatAgent`/`run_chat_loop` into Responses and Messages; never executed by `ToolRegistry`. The Messages surface declares its own server tool instead — a bare `web_search` becomes `{type="web_search_20250305", name="web_search"}` (the search then runs inside the response, and its `encrypted_content` results go back verbatim with the round — `AssistantChatMessage.provider_blocks`) — and a DeepSeek provider on a surface that ignores hosted tools (`config.routing.local_web_search`) gets the local `web_search` tool from `tools/net/search.py` instead.
|
||||
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name. A handler returns its text, or a `ToolOutput(text, images)` when it also produced a picture: `execute` keeps the text-only contract and `execute_output` returns the pair, which `run_chat_loop` stores as the tool message's text plus its `images` (`view_image`, an image body from `fetch_local`, and an MCP `image` content block all take that path). Before running, `execute` answers a call that omits a required argument with the accepted names (`_missing_required_error`) — the handler's bare `TypeError` never reaches the model, and no confirm prompt fires for a call that cannot run. Shape mistakes inside a *present* argument are the tool's own job (see the `chat` bullet). `@tool(writes=("path",))` names a handler's file arguments: one batch applies calls that name the same file one at a time, in call order — the lock is taken before the confirm prompt, so a pending prompt cannot be overtaken — while different files keep running in parallel.
|
||||
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`; resolved `reasoning_effort` on every request (see Config). A round the provider cut off at the output limit is kept, not failed: the answer and its thinking blocks stay in `messages`, the round's tool calls are dropped (a half-written call must not run — the model re-issues it), and up to `max_truncation_continues` (2) further rounds carry on from a truncation notice; 0 spends the turn as unfinished instead, for `/retry`.
|
||||
- **`@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), unless the tool declares its own pre-prompt check (`@tool(validate=…)`, answered in that same place). `run_argv` / `run_argv_batch` / `open_pty` / `write_pty_keys` / `edit_replace` / `edit_lineno` / `fetch_local` do: a `timeout_seconds` that is not a positive number (so a value that would only fail inside `wait_for` never spawns a process), an `argv` sent as a string, an unknown `<key>` marker, an empty `old_string` or `max_replaces < 1`, a backwards line range, a verb outside GET/POST/PUT/DELETE and a URL that cannot be parsed. `prepare_batch_confirm` leaves such a call out of the round's prompt, so nobody is asked about a call that cannot run. `@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; request-prep tool-result cap (each result stored whole, trimmed on the request copy with a `document_id=...` marker); soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`; resolved `reasoning_effort` on every request (see Config). A round the provider cut off at the output limit is kept, not failed: the answer and its thinking blocks stay in `messages`, the round's tool calls are dropped (a half-written call must not run — the model re-issues it), and up to `max_truncation_continues` (2) further rounds carry on from a truncation notice; 0 spends the turn as unfinished instead, for `/retry`.
|
||||
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `provider_tools`; `retry()` continues incomplete turns (user-only, after committed tools — does not re-run those tools — or after a kept truncated answer) and `pending_retry_text` reads a truncation notice as unfinished work.
|
||||
- **`/compact`**: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first. The default summarizer system prompt states that tool calls are unavailable (a `compact_system_prompt` override replaces that default wholesale).
|
||||
- Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **truncated** (`kept_chars`/`continued`), **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
|
||||
- Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`.
|
||||
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `allow_paths` (static out-of-workspace pre-allow `path → read|write|exec`; never prompts), `auto_continue_limits` (auto-raise tool/PTY limits without prompting; default false), `reasoning_effort` / `thinking_budget_tokens` (thinking strength; see Config), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
|
||||
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `allow_paths` (static out-of-workspace pre-allow `path → read|write|exec`; never prompts), `auto_continue_limits` (auto-raise tool/PTY limits without prompting; default false), `reasoning_effort` / `thinking_budget_tokens` (thinking strength; see Config), `max_tool_result_chars` (wire cap; the request-prep pass trims a longer tool result to its head plus a `document_id=...` marker), `hard_max_tool_result_chars` (default 2,000,000: storage ceiling for one result kept whole in its message; a result above it keeps its head behind a leading `[Stored at the …-char ceiling: …]` notice and its document cannot serve the dropped tail), `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens).
|
||||
|
||||
### Tools (`tools/`)
|
||||
|
||||
Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
||||
|
||||
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots **or** a directory-access grant (mode-aware: `read` < `write` < `exec`, default required `write`; a file grant matches only that file); path substring denylist (wins over grants); command denylist via `match_command_denylist` (checks every program of the wrapper/`run` chain, resolved by `tools/command_scan.py`; an entry is a program, plus optional argument words that make it dangerous — `systemctl reboot poweroff …` matches `systemctl reboot` and `systemctl start reboot.target`, not `systemctl status`; basenames are lower-cased, so `RM` / `rm.exe` count; defaults cover privilege `sudo`/`su`/`doas`/`pkexec`/`run0`/`systemd-run`, destructive `rm`/`mkfs`/`dd`/`chown`/`mount`/…, and power state `shutdown`/`reboot`/`poweroff`/`halt`/`init`/`telinit`/`kexec`/`pm-*`/`s2*`; CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); escape error names the `request_directory_access` mode the call needs (`call request_directory_access with mode='write'`).
|
||||
- **`access`** (`tools/access.py`): `request_directory_access` (LOCAL|INSTANCE|SESSION) asks the host hook for out-of-workspace access; grants are one `path → mode` pair in one of three stores — `WorkspacePolicy.config_allow` (static `[agent].allow_paths`), `SessionState.access_grants` (human-approved, persisted on the session row), `WorkspacePolicy.yolo_allow` (`--yes`/`/yolo`, process only). `AccessDecision(mode, persist)` from the hook; denylisted paths can never be granted; cap `MAX_ACCESS_GRANTS`. Not a sandbox: `exec` grants (and any command) reach the whole FS.
|
||||
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range; an image file is refused with a pointer to `view_image`), `view_image` (a picture reaches the model as an image part; format sniffed from the bytes, `detail` = `auto` default / `low` / `high` / `original`), `write_file` (refuses to replace an existing file unless `overwrite=true`; that replacement is the one soft-confirmed), `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `regex_files` (Python `re` per line, not a glob; literal file/dir `path`, no glob expansion; skips VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path` (moves the path into the system trash — freedesktop trash / Finder trash / Recycle Bin — so a delete can be undone; `permanent=true`, `[trash] enabled = false`, or a path under the OS temp dir delete it for good, and a trashing that fails reports the reason and leaves the path alone; [doc/trash.md](doc/trash.md)), `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume any truncated result via its `truncate_token=...`).
|
||||
- **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_batch` (serial steps, `pipe_out` on provider, `mix_stderr`, `stop_on_error`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (key markers) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
|
||||
- **`access`** (`tools/access.py`): `request_directory_access` (LOCAL|INSTANCE|SESSION) asks the host hook for out-of-workspace access; grants are one `path → mode` pair in one of three stores — `WorkspacePolicy.config_allow` (static `[agent].allow_paths`), `SessionState.access_grants` (human-approved, persisted on the session row), `WorkspacePolicy.yolo_allow` (process only: an answer a host keeps without persisting — YOLO/`--yes` never fills it, an out-of-workspace grant is asked either way). `AccessDecision(mode, persist)` from the hook; denylisted paths can never be granted; cap `MAX_ACCESS_GRANTS`. Not a sandbox: `exec` grants (and any command) reach the whole FS.
|
||||
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range; an image file is refused with a pointer to `view_image`), `view_image` (a picture reaches the model as an image part; format sniffed from the bytes, `detail` = `auto` default / `low` / `high` / `original`), `write_file` (refuses to replace an existing file unless `overwrite=true`; that replacement is the one soft-confirmed), `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `regex_files` (Python `re` per line, not a glob; literal file/dir `path`, no glob expansion; skips VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path` (moves the path into the system trash — freedesktop trash / Finder trash / Recycle Bin — so a delete can be undone; `permanent=true`, `[trash] enabled = false`, or a path under the OS temp dir delete it for good, and a trashing that fails reports the reason and leaves the path alone; [doc/trash.md](doc/trash.md)), `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume a truncated `read_file` slice or `@path` mention via its `truncate_token=...`), `read_document` / `search_document` (read a capped tool result by its `document_id`: a line window with optional line numbers, or a per-line regex), `document_info` / `list_documents` (describe or list the session's documents).
|
||||
- **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_batch` (a node is `argv` plus `then` / `else` / `pipe` continuations — one node or a list each — with `cwd` / `env` / `stdin` / `mix_stderr` / `stop_on_error` / `timeout_seconds`; a continuation inherits its parent's `cwd` / `env`, a `pipe` chain runs to its last command and only that exit code decides the branch, max 20 nodes and 4 levels, and a pipe step takes no `then` / `else`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (key markers) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
|
||||
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`<c-c>`, `<esc>`, `<c-up>`, `<c-s-f5>`, `<up*3>`, `<m-x>`, `\xHH` — xterm modifier sequences where they exist; an unknown `<…>` is an error, never typed); secrets via `ask_into_pty` (not `write_pty` data); session limit/idle TTL/output budget; close terminate→kill.
|
||||
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget. The prompt accepts `y`/empty (once), `n`, or `yyy` = stop asking for the rest of the turn (`cli.limits` module flag, reset by `run_turn_with_retries`, `/btw`, session switches, and chat exit). `--auto-continue` / `[agent] auto_continue_limits` set the process-wide default (covers one-shot/PTY); the flag does not imply YOLO and vice versa.
|
||||
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for interpreters — bare shells/REPLs, scripts, and `-c` one-liners alike (`-c` is code only for an interpreter, so `grep -c` / `od -c` stay unflagged; argv + code preview) —, detached runners (`nohup`), and wrapper options that carry a command line (`env -S "…"`, `su -c "…"`, whose text is printed under its own `env -S:` label), each resolved past env wrappers and `<launcher> run` — every wrapper's options are spelled as a `getopt` spec (`command_scan._WRAPPERS`: flags as well as value options, so no option swallows the program), and an option the spec does not know stops the unwrap and is reviewed (`options not understood … the wrapped program is a guess`) while the wrapper itself stays the command the denylist judges; deny may include a user comment for the model. `ssh` / `mosh` are unwrapped like a wrapper (`command_scan`): the program reviewed is the one the client runs on the far end (`ssh prod rm -rf /` is refused as `rm`, `ssh prod bash -c …` prints the remote code, an escalation behind the hop keeps `yolo_skip=False`), and `-o BatchMode=yes` always asks because nothing on either end can prompt. A client with **no** command only opens a remote shell: nothing typed or piped into it (`write_pty`, `run_argv(stdin=…)`) is checked, so opening one is announced and confirmed instead — `remote shell on 'prod' (ssh with no command; its input is not checked)` — while the client itself stays outside the denylist (there is no program to name). The `delete_path` confirm names what will happen — `move path 'x' to the system trash`, or `delete path 'x' permanently` when the call asks for it, the host cleared `[trash]`, or the path is scratch — and the pretty line prints `(to trash)` for a delete that landed there. Session YOLO: `/yolo on|off|once` and `--yes` (skip the soft confirms of `YOLO`-tagged tools — interpreter runs included — while untagged ones still prompt and a command that escalates privileges keeps its confirm through `DangerReason(yolo_skip=False)`; hard denylists unchanged; `once` expires after the next user turn). A round of tool calls asks **once**: `ToolRegistry.prepare_batch_confirm` pre-scans it (unknown tools, undecodable arguments, calls missing a required argument, and calls YOLO already covers are left out — nobody is asked about a call that cannot run or will not ask), two or more soft reasons collapse into one `ToolBatchConfirmHook` prompt whose `BatchConfirmDecision` `execute(..., decision=…)` applies to each call (CLI box lists every call: `y` = allow these, `m` = review them one at a time through the per-call prompt, `N` = deny with one comment shared by all), the answer covers that round only (the next one asks again), and a lone request keeps the plain one-call prompt.
|
||||
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for interpreters — bare shells/REPLs, scripts, and `-c` one-liners alike (`-c` is code only for an interpreter, so `grep -c` / `od -c` stay unflagged; argv + code preview) —, detached runners (`nohup`), and wrapper options that carry a command line (`env -S "…"`, `su -c "…"`, whose text is printed under its own `env -S:` label), each resolved past env wrappers and `<launcher> run` — every wrapper's options are spelled as a `getopt` spec (`command_scan._WRAPPERS`: flags as well as value options, so no option swallows the program), and an option the spec does not know stops the unwrap and is reviewed (`options not understood … the wrapped program is a guess`) while the wrapper itself stays the command the denylist judges; deny may include a user comment for the model. `ssh` / `mosh` are unwrapped like a wrapper (`command_scan`): the program reviewed is the one the client runs on the far end (`ssh prod rm -rf /` is refused as `rm`, `ssh prod bash -c …` prints the remote code, an escalation behind the hop keeps `yolo_skip=False`), and `-o BatchMode=yes` always asks because nothing on either end can prompt. A client with **no** command only opens a remote shell: nothing typed or piped into it (`write_pty`, `run_argv(stdin=…)`) is checked, so opening one is announced and confirmed instead — `remote shell on 'prod' (ssh with no command; its input is not checked)` — while the client itself stays outside the denylist (there is no program to name). The `delete_path` confirm names what will happen — `move path 'x' to the system trash`, or `delete path 'x' permanently` when the call asks for it, the host cleared `[trash]`, or the path is scratch — and the pretty line prints `(to trash)` for a delete that landed there. Session YOLO: `/yolo on|off|once` and `--yes` (skip the soft confirms of `YOLO`-tagged tools — interpreter runs included — while untagged ones still prompt and a command that escalates privileges keeps its confirm through `DangerReason(yolo_skip=False)`; hard denylists unchanged and a directory-access request is asked either way; `once` expires after the next user turn). A round of tool calls asks **once**: `ToolRegistry.prepare_batch_confirm` pre-scans it (unknown tools, undecodable arguments, calls missing a required argument, and calls YOLO already covers are left out — nobody is asked about a call that cannot run or will not ask), two or more soft reasons collapse into one `ToolBatchConfirmHook` prompt whose `BatchConfirmDecision` `execute(..., decision=…)` applies to each call (CLI box lists every call: `y` = allow these, `m` = review them one at a time through the per-call prompt, `N` = deny with one comment shared by all), the answer covers that round only (the next one asks again), and a lone request keeps the plain one-call prompt. Every allow says so in the result itself: the tool text leads with `[approval] user (once)`, `user (round)` (batch), `grant (session)` (a TRUSTABLE grant, so nobody was asked this time) or `yolo (auto)`, and a handler that prompts on its own records it with `tools.context.record_approval` — `workspace.check_command_allowed` (`user`/`grant (process)`), `tools/net/grants.py` (`user`/`grant (process)`) and the skill write path (`user (once|session)`, `grant (session)`, `config (policy)`). One line per call (`Approval` + `most_specific_approval`: the answer closest to a human wins), none for a call that needed no confirmation or ended in `error:`, and it leads so a wire-capped result and `read_document` keep it. A refusal names which no it was (`Denial` / `DenialKind`): `user declined` (with the comment), `no answer within 30s — denied by default` (the prompt's deadline), `prompt cancelled` (Ctrl+C / EOF), or `no confirm hook or non-interactive (denied)` — a timeout is never reported as a decision, and every timed prompt (`workspace.check_command_allowed`, `tools/net/grants.py`, `tools/skills.py`, `tools/access.py`) carries the reason it got into the tool message.
|
||||
- **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems.
|
||||
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core); `wait` (line prompt with timeout; Enter disturbs). Container args are shape-checked (`tools/chat/shape.py`): a mis-typed string `options`/`fields` (or a non-string `question`) returns an explained error — `options="a, b"` used to iterate characters into a per-character menu. `wait`'s `duration` accepts numeric strings (`"5"` = 5s) and rejects booleans/text/containers.
|
||||
- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by the number a listing shows (render order, TOP group first; the stored `id` stays internal); DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work (`[TODO OPEN WORK]`); all-terminal non-empty = hygiene (`[TODO HYGIENE]`); nag channel via `[agent] todo_nag_strategy` (`developer`/`user` = prose nag; `synthetic_tool` = forged `todo_list` call + **real** `stack.render()` result + ToolCall/Result events; `none`).
|
||||
- **`net` / `fetch_local` and `fetch`**: `fetch_local` is the raw HTTP client (GET/POST/PUT/DELETE) via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (`asyncio` `getaddrinfo`). Private/loopback hosts need instance policy grant (CLI timed confirm; **not** YOLO). Public cleartext HTTP and mutating methods soft-confirm (`YOLO`/`TRUSTABLE`). HTTPS→HTTP redirects blocked unless `allow_http_downgrade`. `user_agent` tool arg (or headers) is never replaced by the default when provided. Caps: body bytes/chars, request body size. `fetch` is the remote read-only counterpart, built by the CLI host only for a DeepSeek provider: `POST /v1/tools/fetch` (`lmproto/deepseek/webtools/`) returns a title and Markdown content, billed per successful request (5 QPS, plus a per-domain limit). It does **not** run the local SSRF check — the request leaves from DeepSeek's network, so local/LAN targets are unreachable there (the endpoint answers 403/404) — and never falls back to `fetch_local` on its own. Its body remainder also lives in the memory store, so `get_truncated` resumes without another charge. Both are `READ_ONLY`; `fetch_local` enforces GET-only in a read-only context.
|
||||
- **`net` / `fetch_local` and `fetch`**: `fetch_local` is the raw HTTP client (GET/POST/PUT/DELETE) via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (`asyncio` `getaddrinfo`). Private/loopback hosts need instance policy grant (CLI timed confirm; **not** YOLO). Public cleartext HTTP and mutating methods soft-confirm (`YOLO`/`TRUSTABLE`). HTTPS→HTTP redirects blocked unless `allow_http_downgrade`. `user_agent` tool arg (or headers) is never replaced by the default when provided. Caps: body bytes, request body size. `fetch` is the remote read-only counterpart, built by the CLI host only for a DeepSeek provider: `POST /v1/tools/fetch` (`lmproto/deepseek/webtools/`) returns a title and Markdown content, billed per successful request (5 QPS, plus a per-domain limit). It does **not** run the local SSRF check — the request leaves from DeepSeek's network, so local/LAN targets are unreachable there (the endpoint answers 403/404) — and never falls back to `fetch_local` on its own. Its body is stored whole in the tool message, so a trimmed `document_id` reads the rest with no new request and no new charge. Both are `READ_ONLY`; `fetch_local` enforces GET-only in a read-only context.
|
||||
- **`net` / `web_search`** (`tools/net/search.py`, `tools/net/bing.py`): the search a DeepSeek provider cannot host, as a local tool. `build_web_search_tool(backends)` closes over the sources named by `provider.search_sources` and asks them **in fallback order** (`search_in_order`): `DeepseekSearchBackend` (one `POST /v1/tools/search` through `lmproto/deepseek/webtools/`, no model round — `deepseek_api_base_url` drops the convention's `/anthropic` and any `/v1` before the client appends its path; 408/429/5xx are retried 0.5s/1.0s, 402 and other 4xx are not) and/or `BingSearchBackend` (Bing's RSS results, snippets included, fetched through the same client as `fetch_local`; opt in with `search_sources = ["deepseek", "bing"]` — the default is `["deepseek"]`). Hits are `SearchHit` from `lmproto/search.py` (DeepSeek's `snippet`, else its `summary` cut to 200 chars); the handler returns one `title — url` entry per hit (plus the snippet when the source has one), and a source that raises or finds nothing is skipped and reported only if every source fails. Registered by the CLI host only when `config.routing.local_web_search` says so: a DeepSeek provider with a non-empty `search_sources`, unless the Anthropic convention already hosts the search (`convention = "anthropic"` **and** `provider_tools` ask for `web_search`) — so `provider_tools = []` brings the local tool back on that surface too. `provider_tools` owns the hosted tool, `search_sources` owns this one. Tags LOCAL|READ_ONLY.
|
||||
- **`mcp`** (`tools/mcp.py`): register one server's advertised tools into the catalog as namespaced `mcp__<server>__<tool>` definitions with `ToolSource(kind="mcp", plugin_id=server)`; tags LOCAL (+ READ_ONLY when the server config sets `read_only`) — never YOLO/TRUSTABLE. `register_mcp_tools` purges prior MCP entries then re-registers every connected server (hosts call it on tool-registry rebuild); connections must already be started by `McpManager`.
|
||||
- **`skills`** (`tools/skills.py`): discovery, reading, and writing of skill directories (`plyngent.skills` owns roots, the `SKILL.md` frontmatter subset, the uncached store, and the inside-the-skill path rule). `skill_list` (names, source root, directory, description, shadowed copies, frontmatter notes), `skill_read` (a `SKILL.md` by name, or one bundled file — reuses `read_file` for ranges/line numbers/`truncate_token`), `skill_search` (regex over every visible skill via `regex_files`, hits rebased on `<skill>/<file>`), `skill_create` (new `SKILL.md` + optional `files`), `skill_edit` (replace / `append` / rewrite one file; a rewritten `SKILL.md` is re-parsed and notes a lost description). Reads need no grant (the host publishes the roots as `WorkspacePolicy.static_read`, so `read_file`/`regex_files` see them while writes stay denied); writes go through **`SkillWritePolicy`** on the instance — denylist first, then `SessionState.skill_write_roots` (a `session` answer), then `[skills].allow_write`, then the host's confirm hook (CLI `prompt_skill_write_confirm`: `o`nce/`s`ession/`n`o, timed, default deny, **not** skipped by YOLO); a host with no hook denies. Grants are per **skill directory**; `[skills].enabled = false` drops the tools from the registry (`SKILL_TOOL_NAMES`).
|
||||
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + skills + net (`fetch_local`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `view_image`, `get_truncated`, `listdir`, `glob_paths`, `regex_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `skill_list`/`skill_read`/`skill_search`, `fetch`, `fetch_local`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch_local` accept GET only.
|
||||
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + skills + net (`fetch_local`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `view_image`, `get_truncated`, `read_document`/`search_document`/`document_info`/`list_documents`, `listdir`, `glob_paths`, `regex_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `skill_list`/`skill_read`/`skill_search`, `fetch`, `fetch_local`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch_local` accept GET only.
|
||||
|
||||
### Prompting (`prompting.py`)
|
||||
|
||||
@@ -128,8 +128,8 @@ Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
|
||||
- `/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.
|
||||
- Failed/cancelled turns: user kept; **committed tool rounds kept**, and a batch cut short keeps the calls that already produced a result (`rollback_tail`; written to the store, so a resume continues from them instead of re-running them) — side effects are not re-run on `/retry`, and only unfinished assistant text is dropped; a round cut off at the output limit is not a failure at all: it is kept under a `truncated` notice (see the `run_chat_loop` bullet), which also keeps the turn unfinished for `/retry`; Ctrl+C cancels the in-flight turn (a session-scoped asyncio SIGINT handler routes it, `cli/interrupt.py` — a stray Ctrl+C while the loop waits never tears the REPL down); TTY confirms/ask prompts run off-loop and register their own SIGINT target, so Ctrl+C there cancels just the poll-based read (`wait` reports `cancelled by user`) or is ignored with a hint for readline prompts (`ask_user_*`, confirms) — never the turn, never the loop; auto-retry waits 5s/10s/15s/20s then +10s (10 attempts), its countdown is Ctrl+C-cancellable, and the budget **resets when an attempt completes a model round** (connection recovered); then `/retry`.
|
||||
- Pretty tool lines: every builtin tool prints its `* Verb 'target' ` prefix as the call starts and appends the outcome (`L1-4 (done)`, `(exit code 0)`, `(session 3)`) when the result lands, so a blocking tool stays visible while it runs; namespaced MCP tools share one generic line (`* MCP <server>:<tool> (done)`). Plugin tools and custom tool calls keep the `[tool] …` / `[tool ok] …` whole-line style. Interactive TTY only (`interactive_terminal()`): piped/plain output and `--verbose` keep whole lines. A second call of the same round holds its prefix back until the open row takes its own detail (results arrive in call order), so each call still prints one finished row; a prompt box (or other output) printed while the tool runs *ends* the open prefix as its own line and falls back to whole lines for the rest of the batch — the cursor is never moved, so a prefix wider than the terminal cannot be half-erased. A batch (one round's calls) that only touches todo tools prints one `* Todo x<N>: Push, Update` line plus its last result, whose text already carries the final stack, instead of one header and a full list render per call. `run_argv_batch` lists its commands above its row: one `$ command` line per step, a `pipe_out` chain joined into one line with ` | `, each line elided past 80 characters.
|
||||
- Failed/cancelled turns: user kept; **committed tool rounds kept**, and a batch cut short keeps the calls that already produced a result (`rollback_tail`; written to the store, so a resume continues from them instead of re-running them) — side effects are not re-run on `/retry`, and only unfinished assistant text is dropped; a round cut off at the output limit is not a failure at all: it is kept under a `truncated` notice (see the `run_chat_loop` bullet), which also keeps the turn unfinished for `/retry`; Ctrl+C cancels the in-flight turn (a session-scoped asyncio SIGINT handler routes it, `cli/interrupt.py` — a stray Ctrl+C while the loop waits never tears the REPL down; the handler is rebound to whichever loop owns the session, since asyncio drops it with its loop); TTY confirms/ask prompts run off-loop and register their own SIGINT target, so Ctrl+C there cancels just the poll-based read (`wait` reports `cancelled by user`) or is ignored with a hint for readline prompts (`ask_user_*`, confirms) — never the turn, never the loop (a slash command's own work is a task too — `/compact` cancels its summary request, `/btw` its side turn — and `handle_slash` answers that `CancelledError` with one `cancelled` line, so the REPL survives; before this, nothing was registered as a target during a slash command and the router dropped the signal); auto-retry waits 5s/10s/15s/20s then +10s (10 attempts), its countdown is Ctrl+C-cancellable, and the budget **resets when an attempt completes a model round** (connection recovered); then `/retry`.
|
||||
- Pretty tool lines: every builtin tool prints its `* Verb 'target' ` prefix as the call starts and appends the outcome (`L1-4 (done)`, `(exit code 0)`, `(session 3)`) when the result lands, so a blocking tool stays visible while it runs; namespaced MCP tools share one generic line (`* MCP <server>:<tool> (done)`). Plugin tools and custom tool calls keep the `[tool] …` / `[tool ok] …` whole-line style. Interactive TTY only (`interactive_terminal()`): piped/plain output and `--verbose` keep whole lines. A second call of the same round holds its prefix back until the open row takes its own detail (results arrive in call order), so each call still prints one finished row; a prompt box (or other output) printed while the tool runs *ends* the open prefix as its own line and falls back to whole lines for the rest of the batch — the cursor is never moved, so a prefix wider than the terminal cannot be half-erased. A batch (one round's calls) that only touches todo tools prints one `* Todo x<N>: Push, Update` line plus its last result, whose text already carries the final stack, instead of one header and a full list render per call. `run_argv_batch` lists its commands above its row: one `$ command` line per node, a `pipe` chain joined into one line with ` | `, a `then` / `else` body indented (two spaces per level) under the node it follows, each line elided past 80 characters.
|
||||
- **`plyngent providers`**, **`config path|edit`**. Config open: `$VISUAL`/`$EDITOR` (wait), else system default (`xdg-open` / `open` / `startfile`, non-blocking). `/edit` stays blocking-only. No providers → optional edit then reload when waited.
|
||||
- Tools default on; workspace defaults to cwd; `--max-rounds` default 32. Readline history under platformdirs (`repl_history`). PTY: `close_all` on chat exit.
|
||||
|
||||
|
||||
@@ -315,7 +315,7 @@ Type `/help` in the REPL for the live list. Common ones:
|
||||
| `/sessions` | Sessions for this workspace |
|
||||
| `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) |
|
||||
| `/export [md\|json] [path]` | Transcript from DB (no secrets) |
|
||||
| `/compact` | Soft-compact + model summary into a **new** session |
|
||||
| `/compact` | Soft-compact + model summary into a **new** session (Ctrl+C drops the summary request, and no session is created) |
|
||||
| `/stream` `/verbose` `/markdown` `/tools` `/rounds` | Toggles and limits |
|
||||
| `/yolo [on\|off\|once]` | Soft destructive confirms: sticky skip, off, or next turn only |
|
||||
|
||||
@@ -339,12 +339,14 @@ Ctrl+C cancels the in-flight turn, and during the auto-retry countdown it cancel
|
||||
|
||||
- **Workspace** = root for file/process/VCS tools (default cwd).
|
||||
- **Session** = SQLite chat bound to a workspace path.
|
||||
- Paths outside the workspace need a **directory-access grant**: the model calls `request_directory_access` (`read` / `write` / `exec`), the human approves at the requested level or another one (timed y/N, default deny; `--yes`/`/yolo` auto-approve as a process-only grant), and approval lasts for the session (`/grants` lists/revokes). Static pre-allow lives under `[agent].allow_paths`. The path denylist always wins, and grants gate path-resolving tools only — they are **not** a sandbox (`run_argv`/PTY reach the whole filesystem).
|
||||
- Paths outside the workspace need a **directory-access grant**: the model calls `request_directory_access` (`read` / `write` / `exec`), the human approves at the requested level or another one (timed y/N, default deny — `--yes`/`/yolo` do **not** answer it, and a non-interactive run denies), and approval lasts for the session (`/grants` lists/revokes). Static pre-allow lives under `[agent].allow_paths`. The path denylist always wins, and grants gate path-resolving tools only — they are **not** a sandbox (`run_argv`/PTY reach the whole filesystem).
|
||||
- Resuming a session from another directory prompts: keep session workspace, rebind to current, or abort.
|
||||
|
||||
## Tools (when enabled)
|
||||
|
||||
Default registry: file ops (including `tree` with a markdown bullet-list default, `flat` paths or classic `decorated` on demand; default noise-dir skips), `run_argv` / `run_argv_batch` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), HTTP `fetch_local` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO) plus `fetch` on DeepSeek providers (public pages as Markdown through the provider's Fetch API), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), `wait` (line prompt with timeout; Enter disturbs), `request_directory_access` (out-of-workspace access grants), `get_truncated` (resume any truncated result via its `truncate_token=...`), skills (`skill_list` / `skill_read` / `skill_search` / `skill_create` / `skill_edit`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
|
||||
Default registry: file ops (including `tree` with a markdown bullet-list default, `flat` paths or classic `decorated` on demand; default noise-dir skips), `run_argv` / `run_argv_batch` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), HTTP `fetch_local` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO) plus `fetch` on DeepSeek providers (public pages as Markdown through the provider's Fetch API), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), `wait` (line prompt with timeout; Enter disturbs), `request_directory_access` (out-of-workspace access grants), `get_truncated` (resume a truncated file read or `@path` mention via its `truncate_token=...`), documents (`read_document` / `search_document` / `document_info` / `list_documents` — read or search a capped tool result by its `document_id`), skills (`skill_list` / `skill_read` / `skill_search` / `skill_create` / `skill_edit`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
|
||||
|
||||
A call that needed a confirmation says how it got one: its result leads with `[approval] user (once)` — or `user (round)` for a batch answer, `grant (session)` when an earlier answer already covers it, `yolo (auto)` under YOLO, and `user (process)` / `config (policy)` for the denylist, private-host and skill-write policies. A call that needed none is unchanged.
|
||||
|
||||
**Skills** are instruction directories shared with other harnesses: a folder with a `SKILL.md` (YAML frontmatter — `name`, `description`, … — plus a Markdown body) and optional bundled files (scripts, references). Discovery, roots, grants, and the authoring format are documented in [doc/skills.md](doc/skills.md); the short version is that a session-start notice points the model at them (`skill_list` / `skill_read` / `skill_search`) without listing anything, so what exists (and the bodies) is read on demand rather than injected, and writing a skill is a separate grant (a timed `once`/`session`/`no` prompt, or `[skills].allow_write`) that is **not** a directory-access grant and is never skipped by YOLO.
|
||||
|
||||
@@ -352,7 +354,7 @@ Safety defaults:
|
||||
|
||||
- Paths stay under the workspace unless granted (`request_directory_access` / `[agent].allow_paths`); the refusal names the `mode` that would cover the call; optional `path_denylist` substrings always apply (`tree` also skips denylisted children by default).
|
||||
- Command basename denylist (e.g. dangerous shells/utilities).
|
||||
- Destructive tools (delete/move/overwrite) can require confirm (`confirm_destructive`; default deny in non-TTY). Override for the session with `/yolo on|off|once` or startup `--yes` (path/command denylists still apply).
|
||||
- Destructive tools (delete/move/overwrite) can require confirm (`confirm_destructive`; default deny in non-TTY). Override for the session with `/yolo on|off|once` or startup `--yes` (path/command denylists and a directory-access request still ask). A refusal says which no it was — you declined (with your optional comment), nobody answered within the prompt's deadline, the prompt was cancelled, or no prompt was available.
|
||||
- `write_file` refuses to replace an existing file unless `overwrite=true`; the confirm asks about that replacement.
|
||||
- PTY sessions: caps, idle TTL, output budget; master FD is non-inheritable; sessions closed on chat exit.
|
||||
Prefer file tools over full-screen editors (`vim`/`nano`) for edits. `read_pty` sanitizes CSI/controls
|
||||
@@ -360,7 +362,7 @@ Safety defaults:
|
||||
`write_pty` is literal text only; use `write_pty_keys` for keys and bytes: `<c-c>` `<esc>` `<enter>` `<s-tab>` `<up>` … `<f12>`, `<c-up>` `<s-home>` `<c-s-f5>` for modifier combinations, `<up*3>` to repeat, `<m-x>` for Alt, and `\xHH` / `\uHHHH` for raw bytes. An unknown `<…>` is an error, never text typed into the terminal.
|
||||
For passwords/sudo/ssh prompts use `ask_into_pty` (human types locally; answer never returns to the model).
|
||||
|
||||
- Mis-typed tool arguments are answered, never guessed: a string where an array is expected (`run_argv` `argv`, `ask_user_choice` `options`, `ask_user_form` `fields`), a call that omits a required argument, or a non-numeric `wait` duration (numeric strings are accepted) returns an error showing the shape/names to send — the human never sees a garbage prompt, and no confirm prompt fires for a call that cannot run.
|
||||
- Mis-typed tool arguments are answered, never guessed: a string where an array is expected (`run_argv` `argv`, `ask_user_choice` `options`, `ask_user_form` `fields`), a call that omits a required argument, a non-numeric `wait` duration (numeric strings are accepted), or a `timeout_seconds` that is not a positive number returns an error showing the shape/names to send — the human never sees a garbage prompt, and no confirm prompt fires for a call that cannot run (a tool declares that pre-prompt check with `@tool(validate=…)`; `run_argv`, `run_argv_batch` (also for a tree it cannot build), `open_pty`, `write_pty_keys`, `edit_replace`, `edit_lineno` and `fetch_local` do).
|
||||
|
||||
## Usage / context (CLI)
|
||||
|
||||
|
||||
@@ -84,7 +84,9 @@ back through a surface that outlives it.
|
||||
A prompt that can be answered from the notification reads both surfaces while it
|
||||
blocks, from the first moment — the notification itself only shows up after
|
||||
`grace_seconds`, so waiting for it to appear before listening would block on the
|
||||
terminal alone. Typing at the terminal always wins; a readline-only convenience
|
||||
terminal alone. The terminal keeps its own prompt line with the keys it accepts —
|
||||
a button elsewhere does not say what typing here would do. Typing at the terminal
|
||||
always wins; a readline-only convenience
|
||||
(Tab completion on `ask_user_line`) is what a question gives up in exchange.
|
||||
|
||||
A prompt that *follows* an answer given on the notification — the "why denied"
|
||||
|
||||
@@ -113,6 +113,11 @@
|
||||
# system_prompt = "" # persona off; keep default tool_directives unless also cleared
|
||||
# tool_directives = "" # playbook off (set both "" to disable system entirely)
|
||||
max_tool_result_chars = 32000
|
||||
# Storage ceiling for one tool result kept whole in its message. The wire cap
|
||||
# above trims a result on the request copy and names a document for the stored
|
||||
# remainder (read_document / search_document); a result above this ceiling keeps
|
||||
# its head and a leading notice saying how much was dropped.
|
||||
# hard_max_tool_result_chars = 2000000
|
||||
parallel_tools = true
|
||||
confirm_destructive = true
|
||||
path_denylist = ["/secrets/", ".ssh/", ".gnupg/"]
|
||||
|
||||
@@ -499,13 +499,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.13.5"
|
||||
version = "4.14.3"
|
||||
requires_python = ">=3.11"
|
||||
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||
groups = ["default"]
|
||||
files = [
|
||||
{file = "platformdirs-4.13.5-py3-none-any.whl", hash = "sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e"},
|
||||
{file = "platformdirs-4.13.5.tar.gz", hash = "sha256:05831adf87d392760f4b5c3a0a95579ab5a10b18b9b9bd43680dcb47544788a0"},
|
||||
{file = "platformdirs-4.14.3-py3-none-any.whl", hash = "sha256:cba2d05fe5e18b26b1fa6f376b8e6234c012abe1f8696c486fface7d0054e931"},
|
||||
{file = "platformdirs-4.14.3.tar.gz", hash = "sha256:021add1ab113483edbcd4694f5fbb2d688b893ea30d35e1155bb371cf4be0be8"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -532,102 +532,119 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
version = "9.2.0"
|
||||
requires_python = ">=3.10"
|
||||
summary = "pytest: simple powerful testing with Python"
|
||||
groups = ["dev"]
|
||||
dependencies = [
|
||||
"colorama>=0.4; sys_platform == \"win32\"",
|
||||
"exceptiongroup>=1; python_version < \"3.11\"",
|
||||
"iniconfig>=1.0.1",
|
||||
"packaging>=22",
|
||||
"pluggy<2,>=1.5",
|
||||
"pygments>=2.7.2",
|
||||
"tomli>=1; python_version < \"3.11\"",
|
||||
"iniconfig>=2",
|
||||
"packaging>=24",
|
||||
"pluggy<2,>=1.6",
|
||||
"pygments>=2.15",
|
||||
"tomli>=2; python_version < \"3.11\"",
|
||||
]
|
||||
files = [
|
||||
{file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"},
|
||||
{file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"},
|
||||
{file = "pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588"},
|
||||
{file = "pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1.post1"
|
||||
requires_python = ">=3.10"
|
||||
summary = "Pytest support for asyncio"
|
||||
groups = ["dev"]
|
||||
dependencies = [
|
||||
"backports-asyncio-runner<2,>=1.1; python_version < \"3.11\"",
|
||||
"pytest<10,>=8.4",
|
||||
"pytest>=8.4",
|
||||
"typing-extensions>=4.12; python_version < \"3.13\"",
|
||||
]
|
||||
files = [
|
||||
{file = "pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1"},
|
||||
{file = "pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42"},
|
||||
{file = "pytest_asyncio-1.4.1.post1-py3-none-any.whl", hash = "sha256:0755e488cc0a2b9bd2eab7292cc6390d4cbb8390f1020745647bae5a708e0165"},
|
||||
{file = "pytest_asyncio-1.4.1.post1.tar.gz", hash = "sha256:f582d9ea4a1c082195abe8bd29a1fe106aeb1cd435e3dad2d3bc2749935c36a2"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywinpty"
|
||||
version = "3.1.0"
|
||||
version = "3.1.2"
|
||||
requires_python = ">=3.10"
|
||||
summary = "Pseudo terminal support for Windows from Python."
|
||||
groups = ["default"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "pywinpty-3.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:d01178e9391366bc3325b4c49068cfce95d10d416f6ade62140b274741bc11e0"},
|
||||
{file = "pywinpty-3.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:44918ef8bc408fbf52f54db9b8c33370aee0a59cee76b071a4b4f41ad5ed89c8"},
|
||||
{file = "pywinpty-3.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:08afc428ec8765f2b8b9a9954727e1dafbefcb5912acc65952007779449650a1"},
|
||||
{file = "pywinpty-3.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:98f00e50639e9edb740eb6eedd6c0ab82959f9633029db59363d06973d00ac0d"},
|
||||
{file = "pywinpty-3.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:188923081eea840c15f37c54a29ad6871b04fddf8fbf1c4acf317d0e27bd9cac"},
|
||||
{file = "pywinpty-3.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:714e8aa138bc61dcfd26c139d8cb8e7b954ea5c09c98bb014ab9b1f5741e289c"},
|
||||
{file = "pywinpty-3.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c08a2e5fa7a62f31b5965f0be1b92c67394cd5f75dc0a033d45fadc597f0640d"},
|
||||
{file = "pywinpty-3.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:751d0c727d0e81a246aba722cf0dcb2155fd6bc7b82574b1432f81256157ec73"},
|
||||
{file = "pywinpty-3.1.0.tar.gz", hash = "sha256:14595b68054d61f2f1e90cc2b30be47be201de200ae1851c433d8cded101bce0"},
|
||||
{file = "pywinpty-3.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:980af688956fe78c87882682732e6eea45aa0b8d10a9628812e95878264d5204"},
|
||||
{file = "pywinpty-3.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:81d83678211cc6f8cd2954e98a8a18d8e5de1e0666900a1df8da70f33572d34d"},
|
||||
{file = "pywinpty-3.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fe0abcffd095642c78ba7c6abf4cc8129dce9568e97a1c9194eea23383d0de29"},
|
||||
{file = "pywinpty-3.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:30235f396856fbc03c9bb181b4e27bf50f7b355e432e5731729063d920a09867"},
|
||||
{file = "pywinpty-3.1.2-cp315-cp315-win_amd64.whl", hash = "sha256:b8c47556fbf19595898dd75f45763072ff1160b080eefabeae8cc43c5acbd720"},
|
||||
{file = "pywinpty-3.1.2-cp315-cp315-win_arm64.whl", hash = "sha256:7af19f25e769f0c70f5920ce0964c508abdfe01e676cf05d5ed935848d8c782e"},
|
||||
{file = "pywinpty-3.1.2-cp315-cp315t-win_amd64.whl", hash = "sha256:ed5c5ca288001590fbf1d5a52c6389f0f6ae7e4751bdc8b2f8f9c7249f266f9b"},
|
||||
{file = "pywinpty-3.1.2-cp315-cp315t-win_arm64.whl", hash = "sha256:0a40537712279ed74d5884b1af9037add1ed4b2e2339e9152268b582abad3a57"},
|
||||
{file = "pywinpty-3.1.2.tar.gz", hash = "sha256:03e2d2abe7c0df64db354233c83479b2b43d9fdd1badceea994259ca3819b203"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qh3"
|
||||
version = "2.0.4"
|
||||
version = "2.1.0"
|
||||
requires_python = ">=3.7"
|
||||
summary = "A lightweight and fast implementation of QUIC and HTTP/3"
|
||||
groups = ["default"]
|
||||
marker = "(platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\""
|
||||
files = [
|
||||
{file = "qh3-2.0.4-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a64bf88e94862d54a0406db92b4c51e86775e2fa5223618f92431f7ea0e668a1"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d9d22cdf5a643f3f3236b3e25462d33083f88d200425afd09958cc0d2c5ca54"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6f9d726d83942a2cb20ee127e995a0249dbea3ffe2b3772eb113acfdfd3874a1"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:968a94dcdc85219794d44fdce7b15ce6a90ca295f2574881b623ebe06850311c"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bcae368dc6b5449397f81208493361a5197df1f955d4883b164294915f83638b"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36f201b834d96759a241171a4cdc6ab7442a6c52260d1dcfb1879707b430c6d7"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f47b1f2c5fe8d70472a6134f374b6022ad6f709d6be56282ad4024d28905829"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:edc81973c3823533d75da5570e89214d87aded6205ac76cb9ae3bb9f25b5ffe6"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1964c6471d5e4c074a002bf6a95c1797576165c000d74448260426e10209bed0"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:690cf4bcd8c7baf075187d6197ab31cc933d2aa951311dc65729cb01dccc92dc"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:8449e99c275b04c866f13f24e6d656d609fe8875fedf5896201a93a60bbe1412"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:db25d8132c966ccebc294b8aeb197d2f48d14f772e675ff8282ab958009b801b"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:c03566def5374925a6270bbc1dafc8c8889b3ff3ecd14396979ea07fec9def26"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:19cd7751da3f4d581ea6f5f44510a2b30a19495daba4e632c4e96d8375856694"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-win32.whl", hash = "sha256:c6414837e375fd06f4664b276fcc3e76b17fa2431f650f52f0e41c48489d7e81"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:1923830f3e5e236bcf024ea317625346e5a3e1b08e7852e83293c1e4b3b8d7db"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:9d505d790ab10e378cf4e5c2d6447cb8b602d9cc36a4113160f9ece7bba9abc6"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ec087930c9b43d1e32c5c4cdc8d550fcb72cbfcc957c1d922859ce11b1e55663"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:406577d5fc72bed8d12b6397099b4e164948171dd0a16be8e768b31f1ef155ae"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e94ba4e929e0e54f615c4e28493980af5d82a6d928c644a2da26983df73e110"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:03015a711f8b1ab24825d6ac6d2af1874d4157ac886d236bc19f7a21e69377b6"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:fda466d648cebffccca1a68d9716b31d41eb09b284ecf17705b4a04991dbb881"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:06e8c7ad92fcb482de360af5ae00d8e4559e969d1559a5a88b68989c6bac22c4"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6c0907ab53bd1fb1cca0a309cf26df42c9862e43e40c79d0e5ac3d2b6b747689"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af34fad8554560fa38304f61708176379d5efb192da2ece5fb25dec23c304b36"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:b6760be9bc0401bf7e6c1e889181587619529f34ffcf3f8a8de5f074815dc2ab"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a29a0c05b7986ae0400a4229ca7176805cd44774675663b4ab0d4b6ebef7655c"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:3dcedaeaf791114a050762b097b18839f6cc491dbe83873137b080f7b6dd1411"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:8a8fbc4f30683a47dfb2b9eebc95e5f8b76c3a1eb818b0dd3354aad54c2d99f6"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:d777f66879bda1e2b73caa2ca624045362d3aab8b474d2db868d0f284822e24c"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1dad9bae44479f682cad2e0c0ea8cbd384ce42cd2921f1f1b4c03e1299f6acbc"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-win32.whl", hash = "sha256:45d57837ef9990c58c1776a12d551e7c88f2592a9885b6e781d30e18062b57c6"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:53f7180a74db23f5469a610a6bcdf2b29a4ed45e067a40276c83b1cead3816df"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-win_arm64.whl", hash = "sha256:79e93801a8f33e651a7751bde3e0e120b565a93d18d146bf1fc8127d1669494f"},
|
||||
{file = "qh3-2.0.4.tar.gz", hash = "sha256:72b174c9dabfccdddf050dabc3c1b49972ed7a7564896d3db558e57376187f75"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ba62fe5a0a8394e3cff6b1e205d4cdb21f18d9191193d963fc887a1f7339d3cf"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:962cdfab72c26b5fa9549c5bd1fe645ca437a449ee2c1528aff226e3264452ff"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ec9b4cc8796a7a158f05667200fbc9906ccf8ab6804bf007c61b17901035ebe"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65b2cb6aba980a5821223969d15452e102e01f1b656485b401c40dd0d4d93c8c"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:82505dbb6e5a5aa814fac47789b185b21020692c37ccfd96d428d32c9822ee5d"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5d68107e1c2bb6538bfeddd0238cfd309396acb51e0d3bd0fd7ae87390219b6"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e856c5c9393b1fb4c9e7458165ad2560ec8b0c14e6092c4e8f456dcba8ce3f1f"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f647ef9f76bd289a28baab858dcfd34efd2255a7da274a9e8f45a020be36b795"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:86ebca1a4d4874c619ef7d3eee4d699968b236e4292bb4cbd498c7054cdc5319"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:4c21a3133460449abd93a61cc675dbde893f1094f18bc6f286d44d3d3ae5d462"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:0b3d1ddf862bb9ffde56d4ef4477a0bb86fe60983d6b03b7e8869aa026c4c3d4"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:b812694d3884bf8f92ba05b02889f41ea519821fc98c2634010e5d595ec2c330"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:a80118168ebf68413b089930fcef0d6c7ba2907d61273a4fcd72414322281d75"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a6013c83b0988544ca084feea6c91b4dceb9b9a835a4c2eec0407faf820ba4d2"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:358f66b86c6ff4ac515aaf8c6848bf0e61939b075f8fa455b977d0aa3e7e6860"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1bd9d323d3a059d79c3586c00f973d64ba803234218d47ca2e8a364ec885b9dd"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7644fc608c6d28012f567c5ce060b281c5a014ff60ddf4167ba9b6507ef3e576"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a07c074dcb9d58b41d1d73207357bd70e2617ed9816f36bbc5824eeef4465c1b"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6dd04f2718a3412bdebd580e35ff465128ba3d6dfa967c0ce4186f60a2df060b"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b2304539fee9c70d385bd834402ae3fbfff58fcb2793adf7fd44bd53cc983be0"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:451ae203b2670e256333170afc2ccc558fa15de172412ec02c2ea3e03854645f"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:129ff9821720341c847dffe99f8fe599914f583cfe0c725061ecfd5d578ee2b8"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a648d7ef66894113a23b0b197b9c164e8d6cd6a1175e10c1ba5bfc1d5f02eab1"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:833a93ff543f4e98b71b497841c5c3ac4c7f255d56f544f5223ffd413dca2efb"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e649419464428547e314c8048e5257d9a8cd09e15643405cc9fbe177e10cad37"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1d6e9364a537785755b5b2724c330ed59fbae8bc29df28a1a74cb83ef1defd4c"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:4c3fcb8ec0da9ad2a349a2a0b96902e0432830e6740163863911d1ea88439fad"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d15f365e295d1926bb283324c72dfb2568cdb145a2d521495b562c765a9e3507"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-musllinux_1_1_i686.whl", hash = "sha256:ba8598d3ee4c72325d099714920f23c35844a0e2f0bf20dd56b51d61545bc097"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-musllinux_1_1_riscv64.whl", hash = "sha256:67e6377dad202507d50e4af854a6a464975ed310f4ecc272f83cda73811e9b71"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:9915fd34cf3010f186c783a492c9f3ec31c0e5cad8c5e16a6fe08d2dda1defee"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:71ffe60e5a8dc67b7ecd3b2ba6a105fe4cda8e770d6fae2ff6445b557d0b4d38"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8a1a95c687bd2d26e7cb55dc14095b95ab55f1afcd8adfe1c885fa7818582ad7"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:2eefd993b952bb2bff34396b7abf4cffa46f25667cba85de0dff6e083d1e83ac"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:94412f03ec7b2d27e9f238404de0ab0a293ef4eb172ce1e6f6f6fd11ab857e58"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b66c2ff3d8c5bad514f7f9362e0c9e3e4664209db5c0167278f1d90918bbd12b"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8982eae1cd88e5d7700802b82f29a7cb834d453bb557827010199a037d4062f9"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fbdfefe22dcc6222b853be72f062fc7e84d6c333294310502bf0090e20ac0cf3"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:341793106cb0f803985031690bc2e4e184bbeecbaed63d902921c7dfd07f1808"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b205fd9b5fffcf467331b82ae1f4e360581bd60886d37adf626197e75e0e0bc"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27bd2e3a03985be5e6e6a04faa1f53106c75ad607f662d54b57585a0e52121c1"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1808e123760e78482948b8e6bf5547a8a91637573b89caf165ffe948850d2ba5"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:343780d1f7e8000a6e06458e6940d0a80e29cc2be7e47b60f24f714dd17da690"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:3cb6649dee2f4d3dba008dc9d0e15a75fdb0c16b864535725f2266adc55a54e9"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:23ff27b10d88fae56fd3fb530bd93a19dcce7b3ee921b9cebc4d75767b2b71a1"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:a51ef02b036b2a06293ce87d48105a516a31694a77746062d16016a5aca6a013"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:81a03727a5ddf001fdb57251a86b950ffea47c7a3473641e2707d647d941b398"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5b7e7300dc9352f7d31b4204e6bad6c0a310a12c4b40e36a0ff347f64c8d793f"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-win32.whl", hash = "sha256:19d1b93d669177215675bd21a303763d2222bc9ba1c9c3850e1e9a5bdfe868aa"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:85be2ef6a536a687d12c018f4605a5517b151d02ea7d42a3d9f4386985c735a5"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:0c257cef34cf990da943ffbf12c4ee2ab44d131f4b4024383edae84d19428056"},
|
||||
{file = "qh3-2.1.0.tar.gz", hash = "sha256:622e3c63f2417039785f61d6af2cb26d0f0e5935ac2b84ef85a2bf76862fe6f3"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -773,7 +790,7 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3-future"
|
||||
version = "2.25.903"
|
||||
version = "2.25.905"
|
||||
requires_python = ">=3.7"
|
||||
summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces"
|
||||
groups = ["default"]
|
||||
@@ -783,8 +800,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.903-py3-none-any.whl", hash = "sha256:39352d455023402d86c4c1f567eba4f8b1bbf65571f8df96c2f5697c8058825f"},
|
||||
{file = "urllib3_future-2.25.903.tar.gz", hash = "sha256:ef0011def1c6724f418d7970200ab8ccaf7d034ece152717b368afe512fb6d53"},
|
||||
{file = "urllib3_future-2.25.905-py3-none-any.whl", hash = "sha256:c8e3629be86cf3c074769a23beb2b99993cc3454d2049bd3c0643952f6bfbafb"},
|
||||
{file = "urllib3_future-2.25.905.tar.gz", hash = "sha256:d5bd25dad01d7358b33f4c7b3f225506231158086ff790028c1de5b38c26e89e"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -801,102 +818,127 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "winrt-runtime"
|
||||
version = "3.2.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
version = "4.1.0"
|
||||
requires_python = ">=3.11"
|
||||
summary = "The runtime that every PyWinRT projection package runs on"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"typing-extensions>=4.12.2",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_runtime-3.2.1-cp314-cp314-win32.whl", hash = "sha256:9b6298375468ac2f6815d0c008a059fc16508c8f587e824c7936ed9216480dad"},
|
||||
{file = "winrt_runtime-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:e36e587ab5fd681ee472cd9a5995743f75107a1a84d749c64f7e490bc86bc814"},
|
||||
{file = "winrt_runtime-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:35d6241a2ebd5598e4788e69768b8890ee1eee401a819865767a1fbdd3e9a650"},
|
||||
{file = "winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea"},
|
||||
{file = "winrt_runtime-4.1.0-cp314-cp314-win32.whl", hash = "sha256:c51f2d9c0835e3628b3281eac1d5ef2f7cc772302d6a038f5d2bce9e5591d179"},
|
||||
{file = "winrt_runtime-4.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:af422bbcffa7249770ab118bc5b9aa1f8fcd401566abca478f91cf7bd9a8b7dd"},
|
||||
{file = "winrt_runtime-4.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:453b6d6f8568d639038fe313b37661acf372a8173040f41a36305be95dbf14f0"},
|
||||
{file = "winrt_runtime-4.1.0-cp314-cp314t-win32.whl", hash = "sha256:d8c4654ec0f214a29354b22039d8bd9728968f3195c4cafcded8bfe57cb6fd36"},
|
||||
{file = "winrt_runtime-4.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:77b79a83e8af56ea98ae8644d1e9e753376c8d97586986eb05068d49d653389f"},
|
||||
{file = "winrt_runtime-4.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4fd0f5779c793235aa10d09dfe851c2e7830739b4f41766cc865d4218d2a403f"},
|
||||
{file = "winrt_runtime-4.1.0-cp315-cp315-win32.whl", hash = "sha256:a1418ed9c3592c48fa74f30004abf10d7e96df33f84e782aa61387af16013375"},
|
||||
{file = "winrt_runtime-4.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:281a6085d07dd941873ef16b7bc236f37ad45bd166435e8cabdeff00c67d2830"},
|
||||
{file = "winrt_runtime-4.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:a2bd0d3018f3c2add91f961305e000e56ef9ec222359d069f91cc2e0d4078ace"},
|
||||
{file = "winrt_runtime-4.1.0-cp315-cp315t-win32.whl", hash = "sha256:db36eeae93d36d41e6e7e67b529de122b2798ad687d661e63b287169624f6d44"},
|
||||
{file = "winrt_runtime-4.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:cf9f050cc3565163ec512eda2f834d7c201a2ece54232d7dda770bd9d232d352"},
|
||||
{file = "winrt_runtime-4.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:c299e9b991b623413c5c8773dfaa3f72d039128bdce6ff0b9815c3cb07ba793e"},
|
||||
{file = "winrt_runtime-4.1.0.tar.gz", hash = "sha256:d345452652ce7243ce3d97ebf63067f848717a8328cca8c4e851c2d0a53ef577"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-applicationmodel-core"
|
||||
version = "3.2.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
version = "4!10.0.28000.2957"
|
||||
requires_python = ">=3.11"
|
||||
summary = "PyWinRT projection of the Windows.ApplicationModel.Core namespace"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"winrt-runtime~=3.2.1.0",
|
||||
"winrt-runtime<5,>=4.0.0",
|
||||
"winrt-windows-foundation-collections>=4!10.0.28000.2957",
|
||||
"winrt-windows-foundation>=4!10.0.28000.2957",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_windows_applicationmodel_core-3.2.1-cp314-cp314-win32.whl", hash = "sha256:2a940b018989ba7159ace4c93eab5f5bce303ac5325ab29eeb0effe1d4a486a7"},
|
||||
{file = "winrt_windows_applicationmodel_core-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:52f2e1cd5fa0a5b0592ddc40b423255491a40a2d6e4cf6ae766dd3b4810557ec"},
|
||||
{file = "winrt_windows_applicationmodel_core-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:a0c18662c835fbb5044a5c3ca021d8f2f3f290c6238543ed74112a18e3d7d878"},
|
||||
{file = "winrt_windows_applicationmodel_core-3.2.1.tar.gz", hash = "sha256:7cd21a4a7e25b099703a607f17ce804b5166cdf6444437440590fafea990fb46"},
|
||||
{file = "winrt_windows_applicationmodel_core-4!10.0.28000.2957-py3-none-any.whl", hash = "sha256:55762c64133a38b3c1b50303030f440657bf37b3905f46929b5c5b445799fff1"},
|
||||
{file = "winrt_windows_applicationmodel_core-4!10.0.28000.2957.tar.gz", hash = "sha256:eb1dd1d047a8aec26593956d52ed8204d9f3def71bf64a0fc6f676c0ba1b5663"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-data-xml-dom"
|
||||
version = "3.2.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
version = "4!10.0.28000.2957"
|
||||
requires_python = ">=3.11"
|
||||
summary = "PyWinRT projection of the Windows.Data.Xml.Dom namespace"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"winrt-runtime~=3.2.1.0",
|
||||
"winrt-runtime<5,>=4.0.0",
|
||||
"winrt-windows-foundation-collections>=4!10.0.28000.2957",
|
||||
"winrt-windows-foundation>=4!10.0.28000.2957",
|
||||
"winrt-windows-storage-streams>=4!10.0.28000.2957",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win32.whl", hash = "sha256:77258030a725a32359fd2a93855cafa0e3f3f38e67afd5dfe74a8a215cbb31c4"},
|
||||
{file = "winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:1cf1b6f31fff4e4c0ae30f1643b169da72b3b053a2996010f2c3a1e26b5d4970"},
|
||||
{file = "winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:6de83f853d889444e4897413c2fa8183f86aa602edd34abf36a4fdc69db1650e"},
|
||||
{file = "winrt_windows_data_xml_dom-3.2.1.tar.gz", hash = "sha256:db81f5956bf63ce8c9d8bfb6d0794ab2849f26e62a6256b833af8de3d6451399"},
|
||||
{file = "winrt_windows_data_xml_dom-4!10.0.28000.2957-py3-none-any.whl", hash = "sha256:26a516c6484744260cfb15ec44510d5bf020ee29a946f87f38ef7eac7df3673e"},
|
||||
{file = "winrt_windows_data_xml_dom-4!10.0.28000.2957.tar.gz", hash = "sha256:03643378e9123c85448c57965a056a7f76ebb63f67b00610bd4cf3095d2e9387"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-foundation"
|
||||
version = "3.2.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
version = "4!10.0.28000.2957"
|
||||
requires_python = ">=3.11"
|
||||
summary = "PyWinRT projection of the Windows.Foundation namespace"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"winrt-runtime~=3.2.1.0",
|
||||
"winrt-runtime<5,>=4.0.0",
|
||||
"winrt-windows-foundation-collections>=4!10.0.28000.2957",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win32.whl", hash = "sha256:35e973ab3c77c2a943e139302256c040e017fd6ff1a75911c102964603bba1da"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22a7ebcec0d262e60119cff728f32962a02df60471ded8b2735a655eccc0ef5"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be7fbae829b98a6a946db4fbaf356b11db1fbcbb5d4f37e7a73ac6b25de8b87"},
|
||||
{file = "winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656"},
|
||||
{file = "winrt_windows_foundation-4!10.0.28000.2957-py3-none-any.whl", hash = "sha256:0669de7ce480806a0f9a46d005837799e37f1738911b54c4bc04788b37974e69"},
|
||||
{file = "winrt_windows_foundation-4!10.0.28000.2957.tar.gz", hash = "sha256:6f7d8e932e283991cd931741f84dc7c2e8bc595b4df09de73d16c1d580fc678a"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-foundation-collections"
|
||||
version = "3.2.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
version = "4!10.0.28000.2957"
|
||||
requires_python = ">=3.11"
|
||||
summary = "PyWinRT projection of the Windows.Foundation.Collections namespace"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"winrt-runtime~=3.2.1.0",
|
||||
"winrt-runtime<5,>=4.0.0",
|
||||
"winrt-windows-foundation>=4!10.0.28000.2957",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win32.whl", hash = "sha256:33188ed2d63e844c8adfbb82d1d3d461d64aaf78d225ce9c5930421b413c45ab"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d4cfece7e9c0ead2941e55a1da82f20d2b9c8003bb7a8853bb7f999b539f80a4"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3884146fea13727510458f6a14040b7632d5d90127028b9bfd503c6c655d0c01"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5"},
|
||||
{file = "winrt_windows_foundation_collections-4!10.0.28000.2957-py3-none-any.whl", hash = "sha256:0401c1b6ed512d1fdad8ba1571698e9000ebee6a69781db9a01994ffcc54a503"},
|
||||
{file = "winrt_windows_foundation_collections-4!10.0.28000.2957.tar.gz", hash = "sha256:38989873bbdace2346d24168ceb66359800b7429128ea2ef1a36da55b255af1e"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-storage-streams"
|
||||
version = "4!10.0.28000.2957"
|
||||
requires_python = ">=3.11"
|
||||
summary = "PyWinRT projection of the Windows.Storage.Streams namespace"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"winrt-runtime<5,>=4.1.0",
|
||||
"winrt-windows-foundation-collections>=4!10.0.28000.2957",
|
||||
"winrt-windows-foundation>=4!10.0.28000.2957",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_windows_storage_streams-4!10.0.28000.2957-py3-none-any.whl", hash = "sha256:e113832320f2e7f8b09cedfb400cb7eaf0ac769ad1f2038ddbe88801497916c4"},
|
||||
{file = "winrt_windows_storage_streams-4!10.0.28000.2957.tar.gz", hash = "sha256:6284341b4b01ab872007def0b8d29bf30d84d46f6922f6e42be1d738d6bf2ff7"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-ui-notifications"
|
||||
version = "3.2.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
version = "4!10.0.28000.2957"
|
||||
requires_python = ">=3.11"
|
||||
summary = "PyWinRT projection of the Windows.UI.Notifications namespace"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"winrt-runtime~=3.2.1.0",
|
||||
"winrt-runtime<5,>=4.0.0",
|
||||
"winrt-windows-foundation-collections>=4!10.0.28000.2957",
|
||||
"winrt-windows-foundation>=4!10.0.28000.2957",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_windows_ui_notifications-3.2.1-cp314-cp314-win32.whl", hash = "sha256:00a7579ca0870134c27def1418c259fb18efe5b88aa399de85b4712f1add1b55"},
|
||||
{file = "winrt_windows_ui_notifications-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:943599c727abf710ae94644b1d521e11857bd568e080e894a8be11aa717e383a"},
|
||||
{file = "winrt_windows_ui_notifications-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:2a496c7e796b537966df7b26753b20bc40c5c1fbe96b3b3051a1debc00b80f07"},
|
||||
{file = "winrt_windows_ui_notifications-3.2.1.tar.gz", hash = "sha256:eb29bf1c3306f3ceabd71d092b5505d5fe73fbc0660347dcc63ebc4204d34d21"},
|
||||
{file = "winrt_windows_ui_notifications-4!10.0.28000.2957-py3-none-any.whl", hash = "sha256:2d4e78018df29bb107d0490926ae0a7254be68b6b9c1bd81547d0df569682ea7"},
|
||||
{file = "winrt_windows_ui_notifications-4!10.0.28000.2957.tar.gz", hash = "sha256:5ab89819245c7a90db2d5fe2a0d0cc88158d50bf6fbba12c96145b25bc04a9d3"},
|
||||
]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "plyngent"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
description = "LLM chat and agent toolkit"
|
||||
authors = [
|
||||
{ name = "HivertMoZara", email = "worldmozara@163.com" },
|
||||
|
||||
@@ -28,9 +28,12 @@ from .notices import has_notice as has_notice
|
||||
from .notices import resume_notice as resume_notice
|
||||
from .notices import skill_tools_notice as skill_tools_notice
|
||||
from .notices import truncated_turn_notice as truncated_turn_notice
|
||||
from .tools import Approval as Approval
|
||||
from .tools import BatchConfirmDecision as BatchConfirmDecision
|
||||
from .tools import DangerClassifier as DangerClassifier
|
||||
from .tools import DangerReason as DangerReason
|
||||
from .tools import Denial as Denial
|
||||
from .tools import DenialKind as DenialKind
|
||||
from .tools import ToolBatchConfirmHook as ToolBatchConfirmHook
|
||||
from .tools import ToolConfirmHook as ToolConfirmHook
|
||||
from .tools import ToolConfirmRequest as ToolConfirmRequest
|
||||
@@ -38,6 +41,9 @@ 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 ToolValidator as ToolValidator
|
||||
from .tools import format_approval as format_approval
|
||||
from .tools import most_specific_approval as most_specific_approval
|
||||
from .tools import schema_from_callable as schema_from_callable
|
||||
from .tools import tool as tool
|
||||
from .usage import TokenUsage as TokenUsage
|
||||
|
||||
@@ -21,6 +21,16 @@ if TYPE_CHECKING:
|
||||
type TokenMeasure = Callable[[Sequence[AnyChatMessage]], int]
|
||||
|
||||
DEFAULT_TOOL_RESULT_MAX_CHARS = 32_000
|
||||
# Storage ceiling for one tool result kept whole in its message. A result at or
|
||||
# below this is stored in full (the request-prep cap trims it on the wire); one
|
||||
# above it keeps its head and says at that head how much was dropped, since the
|
||||
# dropped tail is past what a message keeps and no document can serve it.
|
||||
DEFAULT_HARD_MAX_TOOL_RESULT_CHARS = 2_000_000
|
||||
# Leading notice of a body the storage ceiling cut. It leads rather than trails
|
||||
# because the wire cap sends the head only: a notice at the end of a 2 MB body
|
||||
# would be readable only by fetching the whole thing back through its document.
|
||||
_CEILING_NOTICE_HEAD = "[Stored at the {max_chars}-char ceiling: {omitted} "
|
||||
_CEILING_NOTICE_TAIL = "chars beyond it were dropped and cannot be read back]"
|
||||
# Soft context budget in tokens (API-calibrated when possible; else ~4 chars/token).
|
||||
DEFAULT_CONTEXT_MAX_TOKENS = 200_000
|
||||
DEFAULT_OLD_TOOL_RESULT_CHARS = 800
|
||||
@@ -36,26 +46,64 @@ IMAGE_CHARS = 4_096
|
||||
DEFAULT_CONTEXT_MAX_CHARS = DEFAULT_CONTEXT_MAX_TOKENS * 4
|
||||
|
||||
|
||||
def truncate_tool_result(
|
||||
text: str,
|
||||
max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||
) -> str:
|
||||
"""Cap tool output so huge dumps do not flood model context.
|
||||
def cut_at_storage_ceiling(text: str) -> bool:
|
||||
"""Whether a stored tool body was cut by :func:`cap_hard_tool_text`."""
|
||||
head, _, _ = text.partition("\n")
|
||||
return head.startswith("[Stored at the ") and head.endswith(_CEILING_NOTICE_TAIL)
|
||||
|
||||
This is the generic agent-loop cap for arbitrary tool results. It embeds a
|
||||
memory truncate token via :func:`truncate_generic` so the model can always
|
||||
resume the remainder with ``get_truncated`` — resumable or not (remainders
|
||||
live in the in-memory store for the process lifetime). The compact marker
|
||||
``[Truncated (N chars max.; M omitted). truncate_token=...]`` is emitted
|
||||
only when output is actually cut.
|
||||
|
||||
def cap_hard_tool_text(text: str, max_chars: int = DEFAULT_HARD_MAX_TOOL_RESULT_CHARS) -> str:
|
||||
"""Bound what one tool message stores (the storage ceiling).
|
||||
|
||||
The request-prep pass trims a result on the wire and names a document for the
|
||||
stored remainder; this ceiling bounds the stored body itself. A result above
|
||||
it keeps its head behind a leading notice that says how much was dropped: the
|
||||
dropped tail is past what a message keeps, so no document holds it and a
|
||||
reader has to be told that up front. ``max_chars < 1`` stores the result whole.
|
||||
"""
|
||||
if max_chars < 1:
|
||||
if max_chars < 1 or len(text) <= max_chars:
|
||||
return text
|
||||
if len(text) <= max_chars:
|
||||
return text
|
||||
from plyngent.tools.truncate_token import truncate_generic
|
||||
omitted = len(text) - max_chars
|
||||
notice = _CEILING_NOTICE_HEAD.format(max_chars=max_chars, omitted=omitted) + _CEILING_NOTICE_TAIL
|
||||
return f"{notice}\n{text[:max_chars]}"
|
||||
|
||||
return truncate_generic(text, max_chars)
|
||||
|
||||
def cap_tool_message(message: ToolChatMessage, max_chars: int) -> ToolChatMessage:
|
||||
"""Trim one tool result for the wire, naming the document that holds the rest.
|
||||
|
||||
The fixed rule of the request-prep pass: a result over ``max_chars`` keeps its
|
||||
head and gains a marker whose ``document_id`` (the message's ``tool_call_id``)
|
||||
lets the model read the rest with ``read_document`` / ``search_document``. The
|
||||
stored message stays whole; only the returned copy is trimmed. A body the
|
||||
storage ceiling already cut says so in the marker too, so the document is not
|
||||
mistaken for the complete output.
|
||||
"""
|
||||
if max_chars < 1 or len(message.content) <= max_chars:
|
||||
return message
|
||||
from plyngent.tools.documents import document_marker
|
||||
|
||||
total = len(message.content)
|
||||
cut = cut_at_storage_ceiling(message.content)
|
||||
head_len = max(1, max_chars - len(document_marker(message.tool_call_id, max_chars, total, stored_cut=cut)))
|
||||
marker = document_marker(message.tool_call_id, head_len, total, stored_cut=cut)
|
||||
return msgspec.structs.replace(message, content=message.content[:head_len] + marker)
|
||||
|
||||
|
||||
def truncate_for_request(
|
||||
messages: Sequence[AnyChatMessage],
|
||||
*,
|
||||
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||
) -> list[AnyChatMessage]:
|
||||
"""The request copy with every tool result trimmed to the fixed wire cap.
|
||||
|
||||
Runs in the request-prep pass, after soft compact. Each trimmed result names
|
||||
its document, so the model reads the rest on demand instead of carrying it in
|
||||
every request.
|
||||
"""
|
||||
return [
|
||||
cap_tool_message(message, max_tool_result_chars) if isinstance(message, ToolChatMessage) else message
|
||||
for message in messages
|
||||
]
|
||||
|
||||
|
||||
def _content_chars(content: str | list[ChatTextPart | ChatImagePart]) -> int:
|
||||
@@ -131,12 +179,7 @@ def measure_messages_tokens(
|
||||
|
||||
|
||||
def _shrink_tool(message: ToolChatMessage, max_chars: int) -> ToolChatMessage:
|
||||
if len(message.content) <= max_chars:
|
||||
return message
|
||||
return msgspec.structs.replace(
|
||||
message,
|
||||
content=truncate_tool_result(message.content, max_chars),
|
||||
)
|
||||
return cap_tool_message(message, max_chars)
|
||||
|
||||
|
||||
def _tool_indices(messages: Sequence[AnyChatMessage]) -> list[int]:
|
||||
|
||||
+63
-18
@@ -20,6 +20,7 @@ from plyngent.lmproto.openai_compatible.model import (
|
||||
|
||||
from .budget import (
|
||||
DEFAULT_CONTEXT_MAX_TOKENS,
|
||||
DEFAULT_HARD_MAX_TOOL_RESULT_CHARS,
|
||||
DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||
estimate_messages_tokens,
|
||||
)
|
||||
@@ -56,10 +57,20 @@ if TYPE_CHECKING:
|
||||
|
||||
# Per-conversation tracking of lines read with ``with_lineno=True``, keyed by
|
||||
# resolved file path, with the file's mtime at read time (so stale line numbers
|
||||
# after external/process mutations are detected). Set to a fresh ``dict`` at the
|
||||
# start of each user turn via :func:`reset_lineno_tracker`; cleared after the
|
||||
# assistant finishes. Tool handlers read/write via :func:`get_lineno_read_files` /
|
||||
# :func:`lineno_read_lines` / :func:`lineno_read_mtime` / :func:`mark_lineno_read`.
|
||||
# after external/process mutations are detected). The dict itself is owned by
|
||||
# the :class:`ChatAgent` (a fresh one per user turn) and bound into the
|
||||
# consuming task's context via :func:`reset_lineno_tracker` /
|
||||
# :func:`bind_lineno_tracker`; tool handlers read/write it via
|
||||
# :func:`get_lineno_read_files` / :func:`lineno_read_lines` /
|
||||
# :func:`lineno_read_mtime` / :func:`mark_lineno_read`.
|
||||
#
|
||||
# The binding lives in the *consuming task's* context: the host wraps each turn
|
||||
# attempt in its own task (auto-retry, Ctrl+C then ``/retry``), and
|
||||
# ``asyncio.create_task`` copies the context of the caller — not of the previous
|
||||
# attempt. Retrying therefore re-binds the *same* turn-scoped dict; without
|
||||
# that, ``mark_lineno_read`` would silently drop every numbered read (the
|
||||
# variable is unset in the new task) and ``edit_lineno`` would reject all edits
|
||||
# even after the model re-reads the file as its own error message instructs.
|
||||
_lineno_read_files: ContextVar[dict[str, tuple[set[int], int]] | None] = ContextVar(
|
||||
"_lineno_read_files",
|
||||
default=None,
|
||||
@@ -67,7 +78,7 @@ _lineno_read_files: ContextVar[dict[str, tuple[set[int], int]] | None] = Context
|
||||
|
||||
|
||||
def reset_lineno_tracker() -> Token[dict[str, tuple[set[int], int]] | None]:
|
||||
"""Replace the tracker with a fresh empty mapping; return the previous token.
|
||||
"""Bind a fresh empty tracker; return the previous token.
|
||||
|
||||
Call at the start of a new user turn. Restore with
|
||||
``_lineno_read_files.reset(token)`` when the turn completes.
|
||||
@@ -75,6 +86,18 @@ def reset_lineno_tracker() -> Token[dict[str, tuple[set[int], int]] | None]:
|
||||
return _lineno_read_files.set({})
|
||||
|
||||
|
||||
def bind_lineno_tracker(
|
||||
tracker: dict[str, tuple[set[int], int]],
|
||||
) -> Token[dict[str, tuple[set[int], int]] | None]:
|
||||
"""Bind an agent-owned tracker dict; return the previous token.
|
||||
|
||||
``retry`` uses this to re-bind the current turn's dict in a new attempt
|
||||
task, so numbered reads from before the failure stay valid for edits after
|
||||
it (the turn did not end, so its reads should not either).
|
||||
"""
|
||||
return _lineno_read_files.set(tracker)
|
||||
|
||||
|
||||
def get_lineno_read_files() -> set[str]:
|
||||
"""Return the current turn's set of line-numbered file paths.
|
||||
|
||||
@@ -306,6 +329,7 @@ class ChatAgent:
|
||||
stream: bool
|
||||
system_prompt: str | None
|
||||
max_tool_result_chars: int
|
||||
hard_max_tool_result_chars: int
|
||||
parallel_tools: bool
|
||||
max_context_tokens: int
|
||||
todo_stack: TodoStack | None
|
||||
@@ -340,6 +364,7 @@ class ChatAgent:
|
||||
stream: bool = True,
|
||||
system_prompt: str | None = None,
|
||||
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||
hard_max_tool_result_chars: int = DEFAULT_HARD_MAX_TOOL_RESULT_CHARS,
|
||||
parallel_tools: bool = True,
|
||||
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
|
||||
todo_stack: TodoStack | None = None,
|
||||
@@ -350,6 +375,9 @@ class ChatAgent:
|
||||
reminder_last_band: int = 0,
|
||||
provider_tools: Sequence[dict[str, object]] | None = None,
|
||||
) -> None:
|
||||
# Lines read with_lineno this turn (shared dict bound into each attempt
|
||||
# task's context; see the module note above _lineno_read_files).
|
||||
self._lineno_tracker: dict[str, tuple[set[int], int]] = {}
|
||||
self.client = client
|
||||
self.model = model
|
||||
self.tools = tools
|
||||
@@ -363,6 +391,7 @@ class ChatAgent:
|
||||
self.stream = stream
|
||||
self.system_prompt = system_prompt
|
||||
self.max_tool_result_chars = max_tool_result_chars
|
||||
self.hard_max_tool_result_chars = hard_max_tool_result_chars
|
||||
self.parallel_tools = parallel_tools
|
||||
self.max_context_tokens = max_context_tokens
|
||||
self.todo_stack = todo_stack
|
||||
@@ -623,6 +652,7 @@ class ChatAgent:
|
||||
on_limit=self.on_limit,
|
||||
stream=self.stream,
|
||||
max_tool_result_chars=self.max_tool_result_chars,
|
||||
hard_max_tool_result_chars=self.hard_max_tool_result_chars,
|
||||
parallel_tools=self.parallel_tools,
|
||||
max_context_tokens=self.max_context_tokens,
|
||||
todo_stack=self.todo_stack,
|
||||
@@ -676,14 +706,20 @@ class ChatAgent:
|
||||
*images* ride along with the text as content parts, which is the shape
|
||||
the model sees an attachment in (see ``agent.images``).
|
||||
"""
|
||||
_ = reset_lineno_tracker()
|
||||
self._ensure_system_prompt()
|
||||
user_msg = UserChatMessage(content=user_content(user_text, images))
|
||||
self.messages.append(user_msg)
|
||||
await self._persist(user_msg)
|
||||
self._persist_from = len(self.messages)
|
||||
async for event in self._run_from_user_message(user_msg):
|
||||
yield event
|
||||
# A new user turn gets a fresh tracker; the dict is owned by the agent
|
||||
# so a retry (new task, fresh context copy) can re-bind the same dict.
|
||||
self._lineno_tracker = {}
|
||||
token = bind_lineno_tracker(self._lineno_tracker)
|
||||
try:
|
||||
self._ensure_system_prompt()
|
||||
user_msg = UserChatMessage(content=user_content(user_text, images))
|
||||
self.messages.append(user_msg)
|
||||
await self._persist(user_msg)
|
||||
self._persist_from = len(self.messages)
|
||||
async for event in self._run_from_user_message(user_msg):
|
||||
yield event
|
||||
finally:
|
||||
_lineno_read_files.reset(token)
|
||||
|
||||
def _resolve_aside_tools(
|
||||
self,
|
||||
@@ -778,6 +814,7 @@ class ChatAgent:
|
||||
stream=self.stream,
|
||||
system_prompt=self.system_prompt if system_prompt is None else system_prompt,
|
||||
max_tool_result_chars=self.max_tool_result_chars,
|
||||
hard_max_tool_result_chars=self.hard_max_tool_result_chars,
|
||||
parallel_tools=self.parallel_tools,
|
||||
max_context_tokens=self.max_context_tokens,
|
||||
todo_stack=None,
|
||||
@@ -803,8 +840,16 @@ class ChatAgent:
|
||||
user_msg = self.messages[user_index]
|
||||
assert isinstance(user_msg, UserChatMessage)
|
||||
self._ensure_system_prompt()
|
||||
# Already-committed rows stay; write any the failed rollback could not.
|
||||
if self._persist_from < len(self.messages):
|
||||
await self._persist_range(self._persist_from, len(self.messages))
|
||||
async for event in self._run_from_user_message(user_msg):
|
||||
yield event
|
||||
# This attempt runs in a new task whose context copy has no tracker
|
||||
# bound; re-bind the turn's dict so numbered reads before the failure
|
||||
# stay valid for edits after it, and reads during the retry land in the
|
||||
# same dict the next tool call sees.
|
||||
token = bind_lineno_tracker(self._lineno_tracker)
|
||||
try:
|
||||
# Already-committed rows stay; write any the failed rollback could not.
|
||||
if self._persist_from < len(self.messages):
|
||||
await self._persist_range(self._persist_from, len(self.messages))
|
||||
async for event in self._run_from_user_message(user_msg):
|
||||
yield event
|
||||
finally:
|
||||
_lineno_read_files.reset(token)
|
||||
|
||||
@@ -25,7 +25,8 @@ use `run_argv_batch` for ordered pipelines.
|
||||
`skill_read` slices a skill file the same way).
|
||||
- `regex_files`: `pattern` is a Python `re` regex (not a glob, so `*.py` fails) and \
|
||||
`path` is a literal file/dir (no glob expansion).
|
||||
- Truncated results carry a `truncate_token=...`; continue with `get_truncated`.
|
||||
- A long tool result carries a `document_id=...`; read the rest with `read_document` \
|
||||
(line window) or `search_document` (regex).
|
||||
- Prefer `fetch` for public pages (clean Markdown); `fetch_local` for localhost/LAN, \
|
||||
non-GET methods, headers, or raw status; `offset` (0-based) skips leading body characters.
|
||||
- PTY secrets only via `ask_into_pty`; denylists and confirms still apply.
|
||||
|
||||
+33
-19
@@ -25,10 +25,12 @@ from plyngent.typedef import Unset # noqa: TC001
|
||||
|
||||
from .budget import (
|
||||
DEFAULT_CONTEXT_MAX_TOKENS,
|
||||
DEFAULT_HARD_MAX_TOOL_RESULT_CHARS,
|
||||
DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||
cap_hard_tool_text,
|
||||
compact_messages_for_request,
|
||||
estimate_messages_tokens,
|
||||
truncate_tool_result,
|
||||
truncate_for_request,
|
||||
)
|
||||
from .directive_checkpoint import (
|
||||
DEFAULT_DIRECTIVE_REMINDER_TOKENS,
|
||||
@@ -88,7 +90,7 @@ async def _run_one_tool(
|
||||
tools: ToolRegistry,
|
||||
call: AnyAssistantToolCall,
|
||||
*,
|
||||
max_result_chars: int,
|
||||
hard_max_chars: int,
|
||||
decision: BatchConfirmDecision | None = None,
|
||||
) -> tuple[ToolChatMessage, ErrorEvent | None]:
|
||||
if isinstance(call, AssistantFunctionToolCall):
|
||||
@@ -99,13 +101,13 @@ async def _run_one_tool(
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result_text = f"error: tool {call.function.name!r} failed: {exc}"
|
||||
err = ErrorEvent(message=result_text, retryable=True, source="tool")
|
||||
truncated = truncate_tool_result(result_text, max_result_chars)
|
||||
return ToolChatMessage(content=truncated, tool_call_id=call.id), err
|
||||
# Only the text is capped; an image the tool returned rides along whole.
|
||||
truncated = truncate_tool_result(output.text, max_result_chars)
|
||||
return ToolChatMessage(content=cap_hard_tool_text(result_text, hard_max_chars), tool_call_id=call.id), err
|
||||
# The result is stored whole (up to the storage ceiling); the
|
||||
# request-prep pass trims it on the wire and names its document. An image
|
||||
# the tool returned rides along whole.
|
||||
return (
|
||||
ToolChatMessage(
|
||||
content=truncated,
|
||||
content=cap_hard_tool_text(output.text, hard_max_chars),
|
||||
tool_call_id=call.id,
|
||||
images=output.images or UNSET,
|
||||
),
|
||||
@@ -127,7 +129,7 @@ async def _execute_tool_calls(
|
||||
tool_calls: Sequence[AnyAssistantToolCall],
|
||||
messages: list[AnyChatMessage],
|
||||
*,
|
||||
max_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||
hard_max_chars: int = DEFAULT_HARD_MAX_TOOL_RESULT_CHARS,
|
||||
parallel: bool = True,
|
||||
) -> AsyncIterator[AgentEvent]:
|
||||
for call in tool_calls:
|
||||
@@ -149,13 +151,13 @@ async def _execute_tool_calls(
|
||||
if parallel and len(tool_calls) > 1:
|
||||
results = await asyncio.gather(
|
||||
*[
|
||||
_run_one_tool(tools, call, max_result_chars=max_result_chars, decision=decision)
|
||||
_run_one_tool(tools, call, hard_max_chars=hard_max_chars, decision=decision)
|
||||
for call, decision in zip(tool_calls, decisions, strict=True)
|
||||
]
|
||||
)
|
||||
else:
|
||||
results = [
|
||||
await _run_one_tool(tools, call, max_result_chars=max_result_chars, decision=decision)
|
||||
await _run_one_tool(tools, call, hard_max_chars=hard_max_chars, decision=decision)
|
||||
for call, decision in zip(tool_calls, decisions, strict=True)
|
||||
]
|
||||
|
||||
@@ -532,6 +534,7 @@ async def run_chat_loop( # noqa: C901, PLR0915 — multi-phase tool loop
|
||||
on_limit: LimitContinueHook | None = None,
|
||||
stream: bool = True,
|
||||
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||
hard_max_tool_result_chars: int = DEFAULT_HARD_MAX_TOOL_RESULT_CHARS,
|
||||
parallel_tools: bool = True,
|
||||
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
|
||||
todo_stack: TodoStack | None = None,
|
||||
@@ -547,7 +550,14 @@ async def run_chat_loop( # noqa: C901, PLR0915 — multi-phase tool loop
|
||||
When ``stream=True``, uses ``chat_completions(..., stream=True)`` and yields
|
||||
text deltas as chunks arrive; tool calls are merged from stream deltas.
|
||||
Multiple tool calls in one round run in parallel when ``parallel_tools``.
|
||||
Request payloads may shrink older tool results when over ``max_context_tokens``.
|
||||
|
||||
Each round builds a request copy: soft compact shrinks older tool dumps when
|
||||
over ``max_context_tokens``, then every tool result is trimmed to
|
||||
``max_tool_result_chars`` with a marker naming its document (``read_document``
|
||||
/ ``search_document`` read the rest). A tool result is stored whole in its
|
||||
message, up to ``hard_max_tool_result_chars``; a result above that ceiling
|
||||
keeps its head and says at that head how much was dropped, and its document
|
||||
cannot serve the dropped tail.
|
||||
|
||||
*reasoning_effort* is the resolved thinking strength (``""`` = send nothing,
|
||||
the provider's own default); each API surface maps it onto its own wire field
|
||||
@@ -588,13 +598,17 @@ async def run_chat_loop( # noqa: C901, PLR0915 — multi-phase tool loop
|
||||
while True:
|
||||
while rounds_used < allowance:
|
||||
rounds_used += 1
|
||||
# Request copy: shrink old tool dumps, then rewrite forged todo nags
|
||||
# so cleaned stacks do not re-appear with stale OPEN WORK text.
|
||||
request_messages = compact_messages_for_request(
|
||||
messages,
|
||||
max_tokens=max_context_tokens,
|
||||
prompt_tokens_hint=prompt_tokens_hint,
|
||||
sent_estimate_tokens=sent_estimate_tokens,
|
||||
# Request copy: soft-compact old tool dumps, then trim every result to
|
||||
# the fixed wire cap (each names its document), then rewrite forged
|
||||
# todo nags so cleaned stacks do not re-appear with stale OPEN WORK text.
|
||||
request_messages = truncate_for_request(
|
||||
compact_messages_for_request(
|
||||
messages,
|
||||
max_tokens=max_context_tokens,
|
||||
prompt_tokens_hint=prompt_tokens_hint,
|
||||
sent_estimate_tokens=sent_estimate_tokens,
|
||||
),
|
||||
max_tool_result_chars=max_tool_result_chars,
|
||||
)
|
||||
if todo_stack is not None:
|
||||
_ = refresh_synthetic_todo_nags(request_messages, todo_stack)
|
||||
@@ -651,7 +665,7 @@ async def run_chat_loop( # noqa: C901, PLR0915 — multi-phase tool loop
|
||||
tools,
|
||||
tool_calls,
|
||||
messages,
|
||||
max_result_chars=max_tool_result_chars,
|
||||
hard_max_chars=hard_max_tool_result_chars,
|
||||
parallel=parallel_tools,
|
||||
):
|
||||
yield event
|
||||
|
||||
+189
-26
@@ -6,7 +6,7 @@ import types
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from contextlib import AsyncExitStack
|
||||
from dataclasses import dataclass
|
||||
from enum import Flag, auto
|
||||
from enum import Flag, StrEnum, auto
|
||||
from pathlib import Path
|
||||
from typing import Any, cast, get_args, get_origin, get_type_hints, overload
|
||||
|
||||
@@ -30,10 +30,65 @@ class DangerReason:
|
||||
yolo_skip: bool = True
|
||||
|
||||
|
||||
class DenialKind(StrEnum):
|
||||
"""Why a confirmation answered no — the model should not be told it declined."""
|
||||
|
||||
DECLINED = "declined" # the human said no
|
||||
TIMEOUT = "timeout" # nobody answered before the deadline
|
||||
CANCELLED = "cancelled" # the prompt was interrupted (Ctrl+C / EOF)
|
||||
NO_PROMPT = "no_prompt" # nobody could be asked (no hook, non-interactive)
|
||||
|
||||
|
||||
# How each kind reads inside a denial message.
|
||||
_DENIAL_CLAUSES: dict[DenialKind, str] = {
|
||||
DenialKind.DECLINED: "user declined",
|
||||
DenialKind.CANCELLED: "prompt cancelled",
|
||||
DenialKind.NO_PROMPT: "no confirm hook or non-interactive (denied)",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Denial:
|
||||
"""A "no" that says which no it was: the human's, or the clock's.
|
||||
|
||||
``seconds`` is the deadline that expired (a timeout), ``comment`` what the
|
||||
human typed when they declined. A prompt returns this instead of a bare
|
||||
``False`` so the message it produces can be specific.
|
||||
"""
|
||||
|
||||
kind: DenialKind = DenialKind.DECLINED
|
||||
seconds: float | None = None
|
||||
comment: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_answer(cls, answer: object) -> Denial:
|
||||
"""Normalize a hook's refusal: a bare comment means the human declined."""
|
||||
if isinstance(answer, Denial):
|
||||
return answer
|
||||
if isinstance(answer, str) and answer.strip():
|
||||
return cls(comment=answer)
|
||||
return cls()
|
||||
|
||||
@property
|
||||
def user_comment(self) -> str | None:
|
||||
"""The comment that belongs in a message, or ``None``."""
|
||||
return (self.comment or "").strip() or None
|
||||
|
||||
def clause(self) -> str:
|
||||
"""The words a denial message carries after the reason."""
|
||||
if self.kind is DenialKind.TIMEOUT:
|
||||
if self.seconds is not None:
|
||||
return f"no answer within {self.seconds:g}s — denied by default"
|
||||
return "no answer — denied by default"
|
||||
return _DENIAL_CLAUSES[self.kind]
|
||||
|
||||
|
||||
type ToolHandler = Callable[..., Any | Awaitable[Any]]
|
||||
type ToolValidator = Callable[[Mapping[str, object]], str | None]
|
||||
"""A tool's pre-prompt shape check: the error to answer with, or ``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
|
||||
# Returns True to allow, or a Denial (or a bare str comment) to deny.
|
||||
type ToolConfirmResult = bool | str | Denial
|
||||
type ToolConfirmHook = Callable[
|
||||
[str, Mapping[str, object], str],
|
||||
ToolConfirmResult | Awaitable[ToolConfirmResult],
|
||||
@@ -100,6 +155,66 @@ class BatchConfirmDecision:
|
||||
allowed: bool
|
||||
comment: str | None = None
|
||||
each: bool = False
|
||||
# Why the answer was no (declined vs no prompt at all); ``None`` means declined.
|
||||
denial: Denial | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Approval:
|
||||
"""How one call got past a confirmation, as the model reads it back.
|
||||
|
||||
``source`` names who answered — ``user`` (a human at a prompt), ``grant``
|
||||
(trust recorded earlier, so nobody was asked *this* time), ``yolo`` (YOLO
|
||||
mode dropped the soft confirm) or ``config`` (a configured allow, e.g.
|
||||
``[agent].allow_paths`` or ``[skills].allow_write``) — and ``scope`` how far
|
||||
that answer reaches: ``once``, ``round`` (a batch allow), ``session``,
|
||||
``process`` or ``auto`` (mode-driven, not a grant).
|
||||
"""
|
||||
|
||||
source: str
|
||||
scope: str
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
"""``user (once)`` — the words the result's first line carries."""
|
||||
return f"{self.source} ({self.scope})"
|
||||
|
||||
|
||||
# Who answered first when one call carries several records: a human prompt is the
|
||||
# closest answer, an earlier grant sits second, a config allow third, and YOLO
|
||||
# mode says the least about why the call was allowed.
|
||||
_APPROVAL_PRIORITY = {"user": 0, "grant": 1, "config": 2, "yolo": 3}
|
||||
|
||||
|
||||
def format_approval(approval: Approval) -> str:
|
||||
"""The model-facing line naming how a call was approved."""
|
||||
return f"[approval] {approval.label}"
|
||||
|
||||
|
||||
def most_specific_approval(approvals: Sequence[Approval]) -> Approval | None:
|
||||
"""The one record a result carries (ties keep the first recorded)."""
|
||||
if not approvals:
|
||||
return None
|
||||
return min(approvals, key=lambda a: _APPROVAL_PRIORITY.get(a.source, len(_APPROVAL_PRIORITY)))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Gate:
|
||||
"""What the soft gate decided for one call: a denial, else how it was allowed."""
|
||||
|
||||
denied: str | None = None
|
||||
approval: Approval | None = None
|
||||
|
||||
|
||||
def _with_approval(output: ToolOutput, approval: Approval | None) -> ToolOutput:
|
||||
"""Lead *output* with its approval line (nothing to add: unchanged).
|
||||
|
||||
An ``error:`` result carries no line: the call did not finish, so the answer
|
||||
that let it start is not what the model has to read first.
|
||||
"""
|
||||
if approval is None or output.text.startswith("error: "):
|
||||
return output
|
||||
return msgspec.structs.replace(output, text=f"{format_approval(approval)}\n{output.text}")
|
||||
|
||||
|
||||
# One prompt for a round's requests; a single request keeps ``ToolConfirmHook``.
|
||||
@@ -141,6 +256,11 @@ class ToolDefinition:
|
||||
``writes`` names the arguments whose values name files the handler writes.
|
||||
Calls of one batch that name the same file apply one at a time, in call
|
||||
order, so the later one sees what the earlier one wrote.
|
||||
|
||||
``validate`` answers an argument-shape problem the tool can see without
|
||||
running anything (a value that is not the type it needs, a tree that cannot
|
||||
be built). The registry asks it before any confirm prompt, so the human is
|
||||
never shown a call that could only fail.
|
||||
"""
|
||||
|
||||
name: str
|
||||
@@ -149,6 +269,7 @@ class ToolDefinition:
|
||||
handler: ToolHandler
|
||||
tags: ToolTag
|
||||
writes: tuple[str, ...]
|
||||
validate: ToolValidator | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -159,6 +280,7 @@ class ToolDefinition:
|
||||
*,
|
||||
tags: ToolTag = ToolTag.LOCAL,
|
||||
writes: tuple[str, ...] = (),
|
||||
validate: ToolValidator | None = None,
|
||||
) -> None:
|
||||
if not (tags & (ToolTag.LOCAL | ToolTag.PUBLIC)):
|
||||
msg = f"tool {name!r} tags must include LOCAL and/or PUBLIC"
|
||||
@@ -169,6 +291,7 @@ class ToolDefinition:
|
||||
self.handler = handler
|
||||
self.tags = tags
|
||||
self.writes = writes
|
||||
self.validate = validate
|
||||
|
||||
def to_tool_item(self) -> ToolFunctionItem:
|
||||
return ToolFunctionItem(
|
||||
@@ -229,6 +352,7 @@ def _build_definition(
|
||||
description: str | None,
|
||||
tags: ToolTag,
|
||||
writes: tuple[str, ...] = (),
|
||||
validate: ToolValidator | None = None,
|
||||
) -> ToolDefinition:
|
||||
tool_name = name or func.__name__
|
||||
tool_description = description if description is not None else (inspect.getdoc(func) or "")
|
||||
@@ -239,6 +363,7 @@ def _build_definition(
|
||||
handler=func,
|
||||
tags=tags,
|
||||
writes=writes,
|
||||
validate=validate,
|
||||
)
|
||||
|
||||
|
||||
@@ -263,6 +388,7 @@ def tool[**PS, R](
|
||||
description: str | None = None,
|
||||
tags: ToolTag = ToolTag.LOCAL,
|
||||
writes: tuple[str, ...] = (),
|
||||
validate: ToolValidator | None = None,
|
||||
register: bool = True,
|
||||
) -> Callable[[Callable[PS, R]], ToolDefinition]: ...
|
||||
|
||||
@@ -275,6 +401,7 @@ def tool[**PS, R](
|
||||
description: str | None = None,
|
||||
tags: ToolTag = ToolTag.LOCAL,
|
||||
writes: tuple[str, ...] = (),
|
||||
validate: ToolValidator | None = None,
|
||||
register: bool = True,
|
||||
) -> ToolDefinition | Callable[[Callable[PS, R]], ToolDefinition]:
|
||||
"""Define (and by default catalog-register) a function as an agent tool.
|
||||
@@ -282,6 +409,8 @@ def tool[**PS, R](
|
||||
Schema is inferred from type hints; description defaults to the docstring.
|
||||
Default ``tags`` is :attr:`ToolTag.LOCAL`. ``writes`` names the arguments
|
||||
that carry the files the handler writes (:attr:`ToolDefinition.writes`).
|
||||
``validate`` answers an argument-shape problem **before** the call is put in
|
||||
front of the human (:attr:`ToolDefinition.validate`).
|
||||
When ``register`` is true, the definition is added to the process
|
||||
:class:`~plyngent.tools.catalog.ToolCatalog` with the current registration
|
||||
source (builtin unless a plugin context is set). Catalog registration does
|
||||
@@ -296,6 +425,7 @@ def tool[**PS, R](
|
||||
description=description,
|
||||
tags=tags,
|
||||
writes=writes,
|
||||
validate=validate,
|
||||
)
|
||||
if register:
|
||||
_register_definition(definition)
|
||||
@@ -330,6 +460,18 @@ def _missing_required_error(definition: ToolDefinition, args: Mapping[str, objec
|
||||
return f"error: `{definition.name}` is missing required argument(s): {', '.join(missing)}{hint}"
|
||||
|
||||
|
||||
def _shape_error(definition: ToolDefinition, args: Mapping[str, object]) -> str | None:
|
||||
"""Model-facing error when the tool's own pre-prompt check refuses the call.
|
||||
|
||||
Shape mistakes inside a *present* argument used to surface in the handler,
|
||||
i.e. after the confirm prompt, so the human could be asked about a call that
|
||||
could only fail. A tool that declares ``validate`` is answered first.
|
||||
"""
|
||||
if definition.validate is None:
|
||||
return None
|
||||
return definition.validate(args)
|
||||
|
||||
|
||||
def _decode_tool_args(arguments_json: str) -> dict[str, object] | str:
|
||||
"""Decode one call's arguments; a returned string is the model-facing error."""
|
||||
try:
|
||||
@@ -341,11 +483,17 @@ def _decode_tool_args(arguments_json: str) -> dict[str, object] | str:
|
||||
return {str(key): value for key, value in cast("dict[object, object]", raw_args).items()}
|
||||
|
||||
|
||||
def _denied_error(name: str, reason: str, comment: str | None) -> str:
|
||||
"""Model-facing text for a denied soft confirm (carries a user comment if one was given)."""
|
||||
if comment is not None and comment.strip():
|
||||
return f"error: tool {name!r} denied by user confirm ({reason}); user comment: {comment.strip()}"
|
||||
return f"error: tool {name!r} denied by user confirm ({reason})"
|
||||
def _denied_error(name: str, reason: str, denial: Denial | None) -> str:
|
||||
"""Model-facing text for a denied soft confirm.
|
||||
|
||||
The answer that refused the call is named: a human's no carries their
|
||||
comment, a silence carries its deadline, and neither is reported as the
|
||||
other.
|
||||
"""
|
||||
denial = denial if denial is not None else Denial()
|
||||
text = f"error: tool {name!r} denied ({reason}; {denial.clause()})"
|
||||
comment = denial.user_comment
|
||||
return f"{text}; user comment: {comment}" if comment else text
|
||||
|
||||
|
||||
def _as_danger_reason(raw: str | DangerReason | None) -> DangerReason | None:
|
||||
@@ -571,17 +719,16 @@ class ToolRegistry:
|
||||
reason: str,
|
||||
*,
|
||||
tags: ToolTag,
|
||||
) -> str | None:
|
||||
) -> _Gate:
|
||||
if self._on_confirm is None:
|
||||
return f"error: tool {name!r} denied by policy ({reason}; no confirm hook)"
|
||||
return _Gate(denied=_denied_error(name, reason, Denial(DenialKind.NO_PROMPT)))
|
||||
decision = self._on_confirm(name, args, reason)
|
||||
if inspect.isawaitable(decision):
|
||||
decision = await decision
|
||||
if decision is True:
|
||||
await self._record_grant(name, tags=tags)
|
||||
return None
|
||||
comment = decision if isinstance(decision, str) else None
|
||||
return _denied_error(name, reason, comment)
|
||||
return _Gate(approval=Approval("user", "once"))
|
||||
return _Gate(denied=_denied_error(name, reason, Denial.from_answer(decision)))
|
||||
|
||||
async def _apply_batch_decision(
|
||||
self,
|
||||
@@ -590,11 +737,12 @@ class ToolRegistry:
|
||||
decision: BatchConfirmDecision,
|
||||
*,
|
||||
tags: ToolTag,
|
||||
) -> str | None:
|
||||
) -> _Gate:
|
||||
if decision.allowed:
|
||||
await self._record_grant(name, tags=tags)
|
||||
return None
|
||||
return _denied_error(name, reason, decision.comment)
|
||||
return _Gate(approval=Approval("user", "round"))
|
||||
denial = decision.denial if decision.denial is not None else Denial(comment=decision.comment)
|
||||
return _Gate(denied=_denied_error(name, reason, denial))
|
||||
|
||||
def _prepare_confirm(self, name: str, arguments_json: str) -> ToolConfirmRequest | None:
|
||||
"""Soft-confirm request for one call, or ``None`` when it needs none.
|
||||
@@ -611,6 +759,8 @@ class ToolRegistry:
|
||||
args = _decode_tool_args(arguments_json)
|
||||
if isinstance(args, str) or _missing_required_error(definition, args) is not None:
|
||||
return None
|
||||
if _shape_error(definition, args) is not None:
|
||||
return None
|
||||
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
|
||||
@@ -678,7 +828,7 @@ class ToolRegistry:
|
||||
*,
|
||||
tags: ToolTag,
|
||||
decision: BatchConfirmDecision | None = None,
|
||||
) -> str | None:
|
||||
) -> _Gate:
|
||||
"""Soft-confirm gate driven by danger reason + tags + grants + YOLO.
|
||||
|
||||
Pipeline (soft gate only; hard denylists stay in tool handlers)::
|
||||
@@ -690,20 +840,23 @@ class ToolRegistry:
|
||||
else → on_confirm (a tool without the YOLO tag, or a reason YOLO
|
||||
may not drop — a privilege escalation — still prompts
|
||||
under YOLO mode)
|
||||
|
||||
Every allow names how it happened, so the result can say it; a call that
|
||||
needed no confirmation carries no approval.
|
||||
"""
|
||||
if self._danger is None:
|
||||
return None
|
||||
return _Gate()
|
||||
reason = _as_danger_reason(self._danger(name, args))
|
||||
if reason is None:
|
||||
return None
|
||||
return _Gate()
|
||||
if self._yolo and (tags & ToolTag.YOLO) and reason.yolo_skip:
|
||||
return None
|
||||
return _Gate(approval=Approval("yolo", "auto"))
|
||||
|
||||
async with self._confirm_lock:
|
||||
if decision is not None:
|
||||
return await self._apply_batch_decision(name, reason.text, decision, tags=tags)
|
||||
if self._grant_allows(name, tags=tags):
|
||||
return None
|
||||
return _Gate(approval=Approval("grant", "session"))
|
||||
return await self._prompt_soft_confirm(name, args, reason.text, tags=tags)
|
||||
|
||||
async def execute(
|
||||
@@ -744,8 +897,13 @@ class ToolRegistry:
|
||||
missing_error = _missing_required_error(definition, args)
|
||||
if missing_error is not None:
|
||||
return ToolOutput(text=missing_error)
|
||||
check_error = _shape_error(definition, args)
|
||||
if check_error is not None:
|
||||
return ToolOutput(text=check_error)
|
||||
|
||||
async def _run() -> ToolOutput:
|
||||
from plyngent.tools.context import collect_approvals
|
||||
|
||||
missing = self._check_state_tags(definition)
|
||||
if missing is not None:
|
||||
return ToolOutput(text=missing)
|
||||
@@ -754,11 +912,16 @@ class ToolRegistry:
|
||||
async with AsyncExitStack() as held:
|
||||
for lock in self._write_locks_for(definition, args):
|
||||
await held.enter_async_context(lock)
|
||||
denied = await self._maybe_confirm(name, args, tags=definition.tags, decision=decision)
|
||||
if denied is not None:
|
||||
return ToolOutput(text=denied)
|
||||
result = await self._invoke(definition, args)
|
||||
return _without_refused_images(result if isinstance(result, ToolOutput) else ToolOutput(text=result))
|
||||
with collect_approvals() as recorded:
|
||||
gate = await self._maybe_confirm(name, args, tags=definition.tags, decision=decision)
|
||||
if gate.denied is not None:
|
||||
return ToolOutput(text=gate.denied)
|
||||
result = await self._invoke(definition, args)
|
||||
output = _without_refused_images(result if isinstance(result, ToolOutput) else ToolOutput(text=result))
|
||||
# The gate's answer and whatever the handler asked on its own;
|
||||
# the result carries the one closest to a human (see Approval).
|
||||
approvals = [*([gate.approval] if gate.approval is not None else []), *recorded]
|
||||
return _with_approval(output, most_specific_approval(approvals))
|
||||
|
||||
# Bind host-provided state for tools that read contextvars (todo, workspace).
|
||||
# Tag enforcement remains gated by ``auto_bind_state``; binding is always useful
|
||||
|
||||
+118
-19
@@ -36,7 +36,8 @@ _TOOL_ARGS_PREVIEW = 80
|
||||
_ASK_SUBJECT_PREVIEW = 60
|
||||
_PTY_PAYLOAD_PREVIEW = 40
|
||||
# ``run_argv_batch`` lists the commands it runs above its ``* Run batch `` row — one
|
||||
# line per command, a ``pipe_out`` chain joined into one — elided past this width.
|
||||
# line per command, a pipe chain joined into one, a ``then`` / ``else`` body
|
||||
# indented under the node it follows — elided past this width.
|
||||
_RUN_BATCH_CMD_LIMIT = 80
|
||||
_MCP_TOOL_PREFIX = "mcp__"
|
||||
_SKILL_MAIN_FILE = "SKILL.md"
|
||||
@@ -417,32 +418,54 @@ def _batch_steps(args_json: str) -> list[dict[str, object]]:
|
||||
value = json.loads(value)
|
||||
except ValueError:
|
||||
return []
|
||||
return _batch_nodes(value)
|
||||
|
||||
|
||||
def _batch_nodes(value: object) -> list[dict[str, object]]:
|
||||
"""Nodes of one continuation: a single node, or a list of them."""
|
||||
if isinstance(value, dict):
|
||||
return [cast("dict[str, object]", value)]
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
raw_steps = cast("list[object]", value)
|
||||
return [cast("dict[str, object]", step) for step in raw_steps if isinstance(step, dict)]
|
||||
return [cast("dict[str, object]", item) for item in cast("list[object]", value) if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _batch_command_lines(args_json: str) -> list[str]:
|
||||
"""One command per line, a ``pipe_out`` chain joined into one line with ``|``."""
|
||||
lines: list[str] = []
|
||||
piped_from_previous = False
|
||||
for step in _batch_steps(args_json):
|
||||
argv = _str_list(step.get("argv"))
|
||||
command = shlex.join(argv) if argv else "?"
|
||||
if piped_from_previous and lines:
|
||||
lines[-1] = f"{lines[-1]} | {command}"
|
||||
else:
|
||||
lines.append(command)
|
||||
# Same flag spellings the tool itself accepts (bool, or "true"/"1"/…).
|
||||
piped_from_previous = str(step.get("pipe_out") or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
def _batch_chain(node: dict[str, object]) -> str:
|
||||
"""``a | b | c`` for a node and the commands its pipe feeds."""
|
||||
argv = _str_list(node.get("argv"))
|
||||
parts = [shlex.join(argv) if argv else "?"]
|
||||
parts.extend(_batch_chain(child) for child in _batch_nodes(node.get("pipe")))
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
def _batch_node_lines(
|
||||
nodes: list[dict[str, object]],
|
||||
depth: int,
|
||||
prefix: str = "",
|
||||
) -> list[tuple[str, bool]]:
|
||||
"""One line per command, and whether it is a command (``$``) or a branch."""
|
||||
lines: list[tuple[str, bool]] = []
|
||||
for node in nodes:
|
||||
lines.append((f"{' ' * depth}{prefix}{_batch_chain(node)}", not prefix))
|
||||
for branch in ("then", "else"):
|
||||
children = _batch_nodes(node.get(branch))
|
||||
if children:
|
||||
lines.extend(_batch_node_lines(children, depth + 1, f"{branch} "))
|
||||
return lines
|
||||
|
||||
|
||||
def _batch_command_lines(args_json: str) -> list[tuple[str, bool]]:
|
||||
"""One line per command, a pipe chain joined into one line with ``|``."""
|
||||
return _batch_node_lines(_batch_steps(args_json), 0)
|
||||
|
||||
|
||||
def _run_argv_batch_prefix(args_json: str) -> str:
|
||||
"""``* Run batch `` with the commands it runs listed above it."""
|
||||
commands = _batch_command_lines(args_json)
|
||||
body = "\n".join(_pretty_prefix(f"$ {_preview(command, _RUN_BATCH_CMD_LIMIT)}") for command in commands)
|
||||
lines = [
|
||||
_pretty_prefix(f"$ {_preview(text, _RUN_BATCH_CMD_LIMIT)}" if is_command else f"{text}")
|
||||
for text, is_command in _batch_command_lines(args_json)
|
||||
]
|
||||
body = "\n".join(lines)
|
||||
header = _pretty_prefix("* Run batch ")
|
||||
return f"{body}\n{header}" if body else header
|
||||
|
||||
@@ -808,6 +831,66 @@ def _resume_detail(_args_json: str, result: str) -> str:
|
||||
return _range_detail(result) or _pretty_segments(("(done)", "green"))
|
||||
|
||||
|
||||
def _doc_read_prefix(args_json: str) -> str:
|
||||
document_id = _json_str_arg(args_json, "document_id") or "?"
|
||||
return _pretty_prefix(f"* Doc Read '{document_id}' ")
|
||||
|
||||
|
||||
def _doc_read_detail(_args_json: str, result: str) -> str:
|
||||
"""``read_document`` outcome: the line range read, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
return _range_detail(result) or _pretty_segments(("(done)", "green"))
|
||||
|
||||
|
||||
def _doc_search_prefix(args_json: str) -> str:
|
||||
pattern = _json_str_arg(args_json, "pattern") or "?"
|
||||
document_id = _json_str_arg(args_json, "document_id") or "?"
|
||||
return _pretty_prefix(f"* Doc Search '{_preview(pattern, _ASK_SUBJECT_PREVIEW)}' in '{document_id}' ")
|
||||
|
||||
|
||||
def _doc_search_detail(_args_json: str, result: str) -> str:
|
||||
"""``search_document`` outcome: the match count, none, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
if result == "(no matches)":
|
||||
return _pretty_segments(("(no matches)", "dim"))
|
||||
matches = [line for line in result.splitlines() if line and not line.startswith("...[truncated")]
|
||||
unit = "match" if len(matches) == 1 else "matches"
|
||||
return _pretty_segments((f"({len(matches)} {unit})", None))
|
||||
|
||||
|
||||
def _doc_info_prefix(args_json: str) -> str:
|
||||
document_id = _json_str_arg(args_json, "document_id") or "?"
|
||||
return _pretty_prefix(f"* Doc Info '{document_id}' ")
|
||||
|
||||
|
||||
def _doc_info_detail(_args_json: str, result: str) -> str:
|
||||
"""``document_info`` outcome: the described size, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
fields = _status_fields(result)
|
||||
lines = fields.get("lines", "")
|
||||
chars = fields.get("chars", "")
|
||||
text = f"{lines} lines, {chars} chars" if lines and chars else "done"
|
||||
return _pretty_segments((f"({text})", None))
|
||||
|
||||
|
||||
def _doc_list_prefix(_args_json: str) -> str:
|
||||
return _pretty_prefix("* Doc List ")
|
||||
|
||||
|
||||
def _doc_list_detail(_args_json: str, result: str) -> str:
|
||||
"""``list_documents`` outcome: the document count, none, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
if result == "(no documents)":
|
||||
return _pretty_segments(("(no documents)", "dim"))
|
||||
count = len(result.splitlines())
|
||||
unit = "document" if count == 1 else "documents"
|
||||
return _pretty_segments((f"({count} {unit})", None))
|
||||
|
||||
|
||||
def _vcs_kind_detail(_args_json: str, result: str) -> str:
|
||||
"""``vcs_kind`` outcome: the detected kind, or an error."""
|
||||
if result.startswith("error:"):
|
||||
@@ -952,6 +1035,10 @@ _PRETTY_LINES: dict[str, PrettyLine] = {
|
||||
"glob_paths": PrettyLine(prefix=_glob_prefix, detail=_glob_detail),
|
||||
"regex_files": PrettyLine(prefix=_regex_prefix, detail=_regex_detail),
|
||||
"get_truncated": PrettyLine(prefix=_resume_prefix, detail=_resume_detail),
|
||||
"read_document": PrettyLine(prefix=_doc_read_prefix, detail=_doc_read_detail),
|
||||
"search_document": PrettyLine(prefix=_doc_search_prefix, detail=_doc_search_detail),
|
||||
"document_info": PrettyLine(prefix=_doc_info_prefix, detail=_doc_info_detail),
|
||||
"list_documents": PrettyLine(prefix=_doc_list_prefix, detail=_doc_list_detail),
|
||||
"run_argv": PrettyLine(prefix=_run_argv_prefix, detail=_run_argv_detail),
|
||||
"run_argv_batch": PrettyLine(prefix=_run_argv_batch_prefix, detail=_run_argv_batch_detail),
|
||||
"fetch": PrettyLine(prefix=_fetch_prefix, detail=_fetch_detail),
|
||||
@@ -1005,9 +1092,21 @@ def _pretty_line_for(name: str) -> PrettyLine | None:
|
||||
return _PRETTY_LINES.get(name) or _mcp_line(name)
|
||||
|
||||
|
||||
_APPROVAL_LINE = re.compile(r"\A\[approval\] [^\n]*\n")
|
||||
|
||||
|
||||
def _without_approval_line(result: str) -> str:
|
||||
"""The tool's own text, without the leading ``[approval]`` line.
|
||||
|
||||
That line is for the model (the human already saw the prompt); a pretty
|
||||
detail reads the result it knows, not a header in front of it.
|
||||
"""
|
||||
return _APPROVAL_LINE.sub("", result, count=1)
|
||||
|
||||
|
||||
def _pretty_parts(line: PrettyLine, args_json: str, result: str) -> tuple[str, str]:
|
||||
"""Prefix + detail for a resolved pretty tool call."""
|
||||
return line.prefix(args_json), line.detail(args_json, result)
|
||||
return line.prefix(args_json), line.detail(args_json, _without_approval_line(result))
|
||||
|
||||
|
||||
def _echo_stream(text: str) -> None:
|
||||
|
||||
@@ -33,6 +33,7 @@ _MINIMAL_CONFIG = """\
|
||||
# # system_prompt = "" / tool_directives = "" disable each part; both "" → no system.
|
||||
# # Or multi-line '''...''' to override.
|
||||
# max_tool_result_chars = 32000
|
||||
# hard_max_tool_result_chars = 2000000
|
||||
# parallel_tools = true
|
||||
# confirm_destructive = true
|
||||
# path_denylist = ["/secrets/", ".ssh/"]
|
||||
|
||||
@@ -67,6 +67,10 @@ class _RouterState:
|
||||
# Cancel targets, innermost last: a SIGINT cancels the innermost one.
|
||||
targets: list[InterruptTarget] = field(default_factory=list)
|
||||
installed: bool = False
|
||||
# The loop that owns the handler. The flag alone is process-wide, so a later
|
||||
# loop (a new session, another test) must rebind instead of believing the
|
||||
# handler still follows it — asyncio detaches handlers with their loop.
|
||||
loop: asyncio.AbstractEventLoop | None = None
|
||||
# Nesting depth of main-thread blocking reads. A plain counter (not a
|
||||
# ContextVar): asyncio.add_signal_handler freezes the ContextVar snapshot at
|
||||
# install time, so a ContextVar reset after reinstall would leave SIGINT
|
||||
@@ -78,8 +82,14 @@ _router = _RouterState()
|
||||
|
||||
|
||||
def sigint_routed() -> bool:
|
||||
"""Whether the session SIGINT router currently owns SIGINT."""
|
||||
return _router.installed
|
||||
"""Whether the session SIGINT router owns SIGINT on the running loop."""
|
||||
if not _router.installed:
|
||||
return False
|
||||
try:
|
||||
loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
return loop is None or _router.loop is loop
|
||||
|
||||
|
||||
def install_sigint_router() -> bool:
|
||||
@@ -95,13 +105,19 @@ def install_sigint_router() -> bool:
|
||||
Blocking main-thread reads suspend the router via
|
||||
:func:`pause_task_cancel_for_prompt`. Platforms without asyncio signal
|
||||
handlers (Windows proactor) return False and keep the plain
|
||||
KeyboardInterrupt behaviour. Idempotent.
|
||||
KeyboardInterrupt behaviour. Idempotent for the loop that owns it, and a
|
||||
different running loop rebinds the handler to itself.
|
||||
"""
|
||||
if _router.installed:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return False
|
||||
if _router.installed and _router.loop is loop:
|
||||
return True
|
||||
if not _add_router_handler():
|
||||
if not _add_router_handler(loop):
|
||||
return False
|
||||
_router.installed = True
|
||||
_router.loop = loop
|
||||
return True
|
||||
|
||||
|
||||
@@ -111,21 +127,32 @@ def uninstall_sigint_router() -> None:
|
||||
return
|
||||
_router.installed = False
|
||||
_remove_router_handler()
|
||||
_router.loop = None
|
||||
_router.targets.clear()
|
||||
install_keyboard_interrupt_sigint()
|
||||
|
||||
|
||||
def _add_router_handler() -> bool:
|
||||
def _add_router_handler(loop: asyncio.AbstractEventLoop | None = None) -> bool:
|
||||
"""Route SIGINT to the router on *loop* (the running one when omitted)."""
|
||||
target = loop
|
||||
if target is None:
|
||||
try:
|
||||
target = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return False
|
||||
try:
|
||||
asyncio.get_running_loop().add_signal_handler(signal.SIGINT, _route_sigint)
|
||||
target.add_signal_handler(signal.SIGINT, _route_sigint)
|
||||
except RuntimeError, NotImplementedError, ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _remove_router_handler() -> None:
|
||||
loop = _router.loop
|
||||
if loop is None:
|
||||
return
|
||||
with contextlib.suppress(NotImplementedError, RuntimeError, ValueError):
|
||||
_ = asyncio.get_running_loop().remove_signal_handler(signal.SIGINT)
|
||||
_ = loop.remove_signal_handler(signal.SIGINT)
|
||||
|
||||
|
||||
def _route_sigint() -> None:
|
||||
|
||||
+62
-45
@@ -9,7 +9,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from plyngent.agent import BatchConfirmDecision, ToolConfirmRequest
|
||||
from plyngent.agent import BatchConfirmDecision, Denial, DenialKind, ToolConfirmRequest
|
||||
from plyngent.cli.display import close_open_pretty_line
|
||||
from plyngent.cli.interrupt import off_loop_prompt, pause_task_cancel_for_prompt
|
||||
from plyngent.prompting import (
|
||||
@@ -232,6 +232,11 @@ def _decide_continue_limit(reason: str) -> bool:
|
||||
else:
|
||||
# Same read as the timed confirms: stdin or the notification,
|
||||
# whichever answers first, with the terminal's default on silence.
|
||||
# The prompt line still goes to the terminal: the buttons elsewhere
|
||||
# do not say what typing here would do.
|
||||
with contextlib.suppress(OSError):
|
||||
_ = sys.stderr.write(_CONTINUE_PROMPT)
|
||||
_ = sys.stderr.flush()
|
||||
token = (_read_answer(None, wait) or "").strip().lower()
|
||||
if not token:
|
||||
token = "y"
|
||||
@@ -288,8 +293,11 @@ def _tool_facts(args: Mapping[str, object]) -> tuple[str, ...]:
|
||||
return tuple(facts)
|
||||
|
||||
|
||||
def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool | str:
|
||||
"""True allow; False deny; non-empty str = deny with comment for the model."""
|
||||
def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool | Denial:
|
||||
"""True allow; a :class:`Denial` otherwise (declined, cancelled, no prompt)."""
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
return Denial(DenialKind.NO_PROMPT)
|
||||
try:
|
||||
with prompt_wait(
|
||||
"confirm",
|
||||
@@ -300,7 +308,7 @@ def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str
|
||||
_echo_tool_confirm(name, reason)
|
||||
allowed = _confirm_with(wait, "Allow this tool call?", default=False)
|
||||
except NonInteractiveError:
|
||||
return False
|
||||
return Denial(DenialKind.CANCELLED)
|
||||
if allowed:
|
||||
return True
|
||||
try:
|
||||
@@ -309,11 +317,11 @@ def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str
|
||||
default="",
|
||||
).strip()
|
||||
except NonInteractiveError:
|
||||
return False
|
||||
return comment or False
|
||||
return Denial(DenialKind.CANCELLED)
|
||||
return Denial(comment=comment or None)
|
||||
|
||||
|
||||
def prompt_confirm_tool(name: str, args: Mapping[str, object], reason: str) -> bool | str:
|
||||
def prompt_confirm_tool(name: str, args: Mapping[str, object], reason: str) -> bool | Denial:
|
||||
"""Ask whether to allow a dangerous tool call (TTY). Default is deny.
|
||||
|
||||
Prints a multi-line boxed summary, then a short y/N prompt. On deny,
|
||||
@@ -394,11 +402,12 @@ def prompt_policy_command_confirm(
|
||||
basename: str,
|
||||
argv: Sequence[str],
|
||||
timeout_seconds: float,
|
||||
) -> bool:
|
||||
) -> bool | Denial:
|
||||
"""Ask whether to allow a denylisted command for this session (timed).
|
||||
|
||||
Independent of YOLO: always prompts when interactive. Default is **deny**.
|
||||
Timeout, cancel, or non-interactive → False.
|
||||
Independent of YOLO: always prompts when interactive. Default is **deny**,
|
||||
and the answer says which denial it was: declined, timed out, cancelled, or
|
||||
not asked at all.
|
||||
"""
|
||||
argv_preview = " ".join(str(part) for part in argv)
|
||||
reason = (
|
||||
@@ -414,7 +423,7 @@ def prompt_policy_command_confirm(
|
||||
):
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
return False
|
||||
return Denial(DenialKind.NO_PROMPT)
|
||||
backend.echo()
|
||||
backend.secho(format_tool_confirm_box("policy", reason), fg="yellow")
|
||||
backend.echo()
|
||||
@@ -430,10 +439,12 @@ def prompt_policy_command_confirm(
|
||||
fg="red",
|
||||
err=True,
|
||||
)
|
||||
return False
|
||||
return raw.strip().lower() in {"y", "yes"}
|
||||
return Denial(DenialKind.TIMEOUT, seconds=timeout_seconds)
|
||||
if raw.strip().lower() in {"y", "yes"}:
|
||||
return True
|
||||
return Denial(DenialKind.DECLINED)
|
||||
except NonInteractiveError, KeyboardInterrupt, EOFError:
|
||||
return False
|
||||
return Denial(DenialKind.CANCELLED)
|
||||
|
||||
|
||||
def prompt_policy_fetch_confirm(
|
||||
@@ -441,12 +452,12 @@ def prompt_policy_fetch_confirm(
|
||||
port: int,
|
||||
url: str,
|
||||
timeout_seconds: float,
|
||||
) -> bool:
|
||||
) -> bool | Denial:
|
||||
"""Ask whether to allow fetch_local to a private/loopback host (timed).
|
||||
|
||||
Independent of YOLO: always prompts when interactive. Default is **deny**.
|
||||
Timeout, cancel, or non-interactive → False. On approve, the host:port is
|
||||
granted for the rest of this process (see net grants).
|
||||
Independent of YOLO: always prompts when interactive. Default is **deny**,
|
||||
and the answer says which denial it was (see the command policy prompt).
|
||||
On approve, the host:port is granted for the rest of this process.
|
||||
"""
|
||||
reason = (
|
||||
f"fetch_local targets private/loopback host {host!r} port {port}\n"
|
||||
@@ -461,7 +472,7 @@ def prompt_policy_fetch_confirm(
|
||||
):
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
return False
|
||||
return Denial(DenialKind.NO_PROMPT)
|
||||
backend.echo()
|
||||
backend.secho(format_tool_confirm_box("policy", reason), fg="yellow")
|
||||
backend.echo()
|
||||
@@ -478,10 +489,12 @@ def prompt_policy_fetch_confirm(
|
||||
fg="red",
|
||||
err=True,
|
||||
)
|
||||
return False
|
||||
return raw.strip().lower() in {"y", "yes"}
|
||||
return Denial(DenialKind.TIMEOUT, seconds=timeout_seconds)
|
||||
if raw.strip().lower() in {"y", "yes"}:
|
||||
return True
|
||||
return Denial(DenialKind.DECLINED)
|
||||
except NonInteractiveError, KeyboardInterrupt, EOFError:
|
||||
return False
|
||||
return Denial(DenialKind.CANCELLED)
|
||||
|
||||
|
||||
def prompt_skill_write_confirm(
|
||||
@@ -494,8 +507,9 @@ def prompt_skill_write_confirm(
|
||||
|
||||
``o``/``y`` allows this write; ``s`` allows this skill directory for the rest
|
||||
of the chat; anything else (or a timeout, cancel, or non-interactive stdin)
|
||||
denies. Independent of YOLO: a skill directory is not a path grant, so the
|
||||
human is asked even with ``--yes`` unless ``[skills].allow_write`` is set.
|
||||
denies, and the answer says which denial it was. Independent of YOLO: a skill
|
||||
directory is not a path grant, so the human is asked even with ``--yes``
|
||||
unless ``[skills].allow_write`` is set.
|
||||
"""
|
||||
lines = [
|
||||
f"action: {action} skill {skill_dir.name!r}",
|
||||
@@ -517,7 +531,7 @@ def prompt_skill_write_confirm(
|
||||
):
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
return SkillWriteDecision(allow=False)
|
||||
return SkillWriteDecision(allow=False, denial=Denial(DenialKind.NO_PROMPT))
|
||||
backend.echo()
|
||||
backend.secho(format_tool_confirm_box(f"skills {action}", "\n".join(lines)), fg="yellow")
|
||||
backend.echo()
|
||||
@@ -531,15 +545,15 @@ def prompt_skill_write_confirm(
|
||||
if raw is None:
|
||||
with contextlib.suppress(OSError, NonInteractiveError):
|
||||
backend.secho(f"[skills] timed out after {timeout_seconds:g}s — denied", fg="red", err=True)
|
||||
return SkillWriteDecision(allow=False)
|
||||
return SkillWriteDecision(allow=False, denial=Denial(DenialKind.TIMEOUT, seconds=timeout_seconds))
|
||||
token = raw.strip().lower()
|
||||
if token in {"s", "session"}:
|
||||
return SkillWriteDecision(allow=True, session=True)
|
||||
if token in {"o", "once", "y", "yes"}:
|
||||
return SkillWriteDecision(allow=True)
|
||||
return SkillWriteDecision(allow=False)
|
||||
return SkillWriteDecision(allow=False, denial=Denial(DenialKind.DECLINED))
|
||||
except NonInteractiveError, KeyboardInterrupt, EOFError:
|
||||
return SkillWriteDecision(allow=False)
|
||||
return SkillWriteDecision(allow=False, denial=Denial(DenialKind.CANCELLED))
|
||||
|
||||
|
||||
def prompt_directory_access_confirm(
|
||||
@@ -547,12 +561,12 @@ def prompt_directory_access_confirm(
|
||||
mode: AccessMode,
|
||||
reason: str,
|
||||
timeout_seconds: float,
|
||||
) -> AccessDecision | str | None:
|
||||
) -> AccessDecision | str | Denial:
|
||||
"""Ask whether to grant access to *path* at *mode* (timed; default deny).
|
||||
|
||||
``y`` approves the requested level; ``read`` / ``write`` / ``exec`` approve
|
||||
that level instead. The host's hook decides YOLO/``--yes`` before calling
|
||||
this. Timeout, cancel, or non-interactive → ``None`` (deny).
|
||||
that level instead. Nothing answers this prompt for the human, and a denial
|
||||
says which one it was: declined, timed out, cancelled, or not asked at all.
|
||||
"""
|
||||
kind = "directory" if path.is_dir() else "file"
|
||||
lines = [
|
||||
@@ -575,7 +589,7 @@ def prompt_directory_access_confirm(
|
||||
):
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
return None
|
||||
return Denial(DenialKind.NO_PROMPT)
|
||||
backend.echo()
|
||||
backend.secho(
|
||||
format_tool_confirm_box("request_directory_access", "\n".join(lines)),
|
||||
@@ -596,20 +610,23 @@ def prompt_directory_access_confirm(
|
||||
fg="red",
|
||||
err=True,
|
||||
)
|
||||
return None
|
||||
return Denial(DenialKind.TIMEOUT, seconds=timeout_seconds)
|
||||
token = raw.strip().lower()
|
||||
if token in {"y", "yes"}:
|
||||
return AccessDecision(mode)
|
||||
level = parse_access_mode(token)
|
||||
if level is not None:
|
||||
return AccessDecision(level)
|
||||
return None
|
||||
return Denial(DenialKind.DECLINED)
|
||||
except NonInteractiveError, KeyboardInterrupt, EOFError:
|
||||
return None
|
||||
return Denial(DenialKind.CANCELLED)
|
||||
|
||||
|
||||
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool | str:
|
||||
"""Async confirm: True allow, False deny, str = deny with user comment."""
|
||||
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool | Denial:
|
||||
"""Async confirm: True allows; a :class:`Denial` says why it did not."""
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
return Denial(DenialKind.NO_PROMPT)
|
||||
facts = _tool_facts(args)
|
||||
try:
|
||||
# Box is printed inside the worker thread via confirm's backend.
|
||||
@@ -622,7 +639,7 @@ async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reaso
|
||||
|
||||
allowed = await run_prompt_async(_run)
|
||||
except NonInteractiveError:
|
||||
return False
|
||||
return Denial(DenialKind.CANCELLED)
|
||||
if allowed:
|
||||
return True
|
||||
try:
|
||||
@@ -633,8 +650,8 @@ async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reaso
|
||||
)
|
||||
).strip()
|
||||
except NonInteractiveError:
|
||||
return False
|
||||
return comment or False
|
||||
return Denial(DenialKind.CANCELLED)
|
||||
return Denial(comment=comment or None)
|
||||
|
||||
|
||||
async def prompt_confirm_batch_async(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
|
||||
@@ -649,14 +666,14 @@ async def prompt_confirm_batch_async(requests: Sequence[ToolConfirmRequest]) ->
|
||||
|
||||
return await run_prompt_async(lambda: _prompt_confirm_batch_sync(requests))
|
||||
except NonInteractiveError:
|
||||
return BatchConfirmDecision(allowed=False)
|
||||
return BatchConfirmDecision(allowed=False, denial=Denial(DenialKind.CANCELLED))
|
||||
|
||||
|
||||
def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
# Non-interactive: deny, like the default of the single prompt.
|
||||
return BatchConfirmDecision(allowed=False)
|
||||
return BatchConfirmDecision(allowed=False, denial=Denial(DenialKind.NO_PROMPT))
|
||||
tools = tuple(dict.fromkeys(request.name for request in requests))
|
||||
with prompt_wait(
|
||||
"batch",
|
||||
@@ -685,7 +702,7 @@ def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchC
|
||||
_ = sys.stderr.flush()
|
||||
token = (_read_answer(None, wait) or "n").strip().lower()
|
||||
except NonInteractiveError:
|
||||
return BatchConfirmDecision(allowed=False)
|
||||
return BatchConfirmDecision(allowed=False, denial=Denial(DenialKind.CANCELLED))
|
||||
if token in _BATCH_MANUAL_TOKENS:
|
||||
return BatchConfirmDecision(allowed=False, each=True)
|
||||
if token in _BATCH_YES_TOKENS:
|
||||
@@ -696,8 +713,8 @@ def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchC
|
||||
default="",
|
||||
).strip()
|
||||
except NonInteractiveError:
|
||||
comment = ""
|
||||
return BatchConfirmDecision(allowed=False, comment=comment or None)
|
||||
return BatchConfirmDecision(allowed=False, denial=Denial(DenialKind.CANCELLED))
|
||||
return BatchConfirmDecision(allowed=False, comment=comment or None, denial=Denial(DenialKind.DECLINED))
|
||||
|
||||
|
||||
def format_batch_confirm_box(requests: Sequence[ToolConfirmRequest]) -> str:
|
||||
|
||||
@@ -15,8 +15,9 @@ the file:
|
||||
``[providers.*.models.*].image_in`` skips it with a note instead of letting
|
||||
the provider answer 400.
|
||||
- any other file is read as text and appended to the message under a
|
||||
``--- @path ---`` header, capped like a tool result; the remainder stays
|
||||
resumable through the ``truncate_token`` in the marker.
|
||||
``--- @path ---`` header, capped at the tool-result size; the remainder waits
|
||||
in memory and the marker's ``truncate_token`` resumes it through
|
||||
``get_truncated``.
|
||||
|
||||
Images and text both keep the typed ``@path`` in the message, so the model can
|
||||
tell which attachment is which.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shlex
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast, override
|
||||
@@ -575,7 +576,8 @@ def status_cmd(state: ReplState) -> None:
|
||||
f"yolo={state.effective_yolo()}\n"
|
||||
f"context_tokens={ctx_tilde}{ctx_tokens}/{ctx_budget} ({ctx_tag}) "
|
||||
f"context_chars={ctx_chars} "
|
||||
f"tool_result_max={state.agent.max_tool_result_chars}\n"
|
||||
f"tool_result_max={state.agent.max_tool_result_chars} "
|
||||
f"tool_result_store_max={state.agent.hard_max_tool_result_chars}\n"
|
||||
f"peak_prompt_tokens={state.agent.peak_prompt_tokens} "
|
||||
f"reminder_band={state.agent.reminder_last_band} "
|
||||
f"reminder_every={state.agent.directive_reminder_tokens or 'off'}\n"
|
||||
@@ -1638,6 +1640,11 @@ async def handle_slash(state: ReplState, line: str) -> bool:
|
||||
args[0] = args[0].lower()
|
||||
try:
|
||||
await awaitlet.async_def(_run_slash_argv, args, state)
|
||||
except asyncio.CancelledError:
|
||||
# A command's own cancellable work was stopped (``/btw``'s side turn, a
|
||||
# prompt): the interrupt belongs to that command. Letting it through
|
||||
# would cancel this REPL task and end the session instead.
|
||||
click.secho("cancelled", fg="yellow")
|
||||
except ReplExitError:
|
||||
return False
|
||||
except click.ClickException as exc:
|
||||
|
||||
+47
-16
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
@@ -33,6 +34,7 @@ from plyngent.tools.view import MemoryViewStore, session_data_view
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from plyngent.agent import Denial
|
||||
from plyngent.config.models import Provider
|
||||
from plyngent.config.store import ConfigStore
|
||||
from plyngent.memory import MemoryStore
|
||||
@@ -228,6 +230,18 @@ class ReplState:
|
||||
if hasattr(self, "agent") and self.agent.tools is not None:
|
||||
self.agent.tools.set_session_state(self.session_state)
|
||||
self.agent.tools.set_instance_state(self.instance_state)
|
||||
self._bind_document_store()
|
||||
|
||||
def _bind_document_store(self) -> None:
|
||||
"""Expose this session's tool results to the document tools.
|
||||
|
||||
The store resolves a ``document_id`` against the agent's live history,
|
||||
which reads whole (uncapped) tool results and re-reads whatever a resume
|
||||
loaded from the durable store.
|
||||
"""
|
||||
from plyngent.tools.documents import DocumentStore
|
||||
|
||||
self.instance_state.extras["documents"] = DocumentStore(lambda: self.agent.messages)
|
||||
|
||||
async def persist_todo_stack(self) -> None:
|
||||
"""Write the in-memory todo stack to the active session row."""
|
||||
@@ -284,13 +298,16 @@ class ReplState:
|
||||
mode: AccessMode,
|
||||
reason: str,
|
||||
timeout_seconds: float,
|
||||
) -> AccessDecision | str | None:
|
||||
"""Directory-access confirm: YOLO/``--yes`` auto-approves, else ask."""
|
||||
from plyngent.cli.limits import prompt_directory_access_confirm
|
||||
from plyngent.tools.access import AccessDecision as Decision
|
||||
) -> AccessDecision | str | Denial:
|
||||
"""Directory-access confirm: always ask, whatever YOLO mode says.
|
||||
|
||||
An out-of-workspace path is a grant on the filesystem rather than one
|
||||
risky call, so ``--yes`` / ``/yolo`` do not answer it — the command
|
||||
denylist and a skill write are not skipped either. A non-interactive run
|
||||
denies.
|
||||
"""
|
||||
from plyngent.cli.limits import prompt_directory_access_confirm
|
||||
|
||||
if self.effective_yolo() != "off":
|
||||
return Decision(mode, persist=False)
|
||||
return prompt_directory_access_confirm(path, mode, reason, timeout_seconds)
|
||||
|
||||
def _install_config_access(self) -> None:
|
||||
@@ -487,6 +504,7 @@ class ReplState:
|
||||
stream=self.stream_enabled,
|
||||
system_prompt=system_prompt,
|
||||
max_tool_result_chars=agent_cfg.max_tool_result_chars,
|
||||
hard_max_tool_result_chars=agent_cfg.hard_max_tool_result_chars,
|
||||
parallel_tools=agent_cfg.parallel_tools,
|
||||
max_context_tokens=agent_cfg.max_context_tokens,
|
||||
todo_stack=self.todo_stack,
|
||||
@@ -990,16 +1008,29 @@ class ReplState:
|
||||
hint = self.agent.last_request_usage.prompt_tokens
|
||||
# Approximate: calibrate against current full history char-est.
|
||||
sent_est = estimate_messages_tokens(messages)
|
||||
summary = await summarize_messages(
|
||||
self.client,
|
||||
messages,
|
||||
model=self.model,
|
||||
max_context_tokens=self.agent.max_context_tokens,
|
||||
prompt_tokens_hint=hint,
|
||||
sent_estimate_tokens=sent_est,
|
||||
system_prompt=self.config.agent_config.compact_system_prompt or None,
|
||||
user_prefix=self.config.agent_config.compact_user_prefix or None,
|
||||
)
|
||||
# The summary is the only long wait in here, and nothing has been
|
||||
# created yet, so it is the safe place to be interrupted: Ctrl+C drops
|
||||
# the request and reports back (see the CancelledError below). Everything
|
||||
# after it is a couple of DB writes that build the new session, and a
|
||||
# cancel that landed there would leave it half-seeded.
|
||||
from plyngent.cli.retry import run_cancellable
|
||||
|
||||
try:
|
||||
summary = await run_cancellable(
|
||||
summarize_messages(
|
||||
self.client,
|
||||
messages,
|
||||
model=self.model,
|
||||
max_context_tokens=self.agent.max_context_tokens,
|
||||
prompt_tokens_hint=hint,
|
||||
sent_estimate_tokens=sent_est,
|
||||
system_prompt=self.config.agent_config.compact_system_prompt or None,
|
||||
user_prefix=self.config.agent_config.compact_user_prefix or None,
|
||||
)
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
msg = "compaction cancelled (nothing changed)"
|
||||
raise ValueError(msg) from None
|
||||
carried_todos = self.todo_stack.to_raw()
|
||||
session_name = name or f"compact-from-{old_id}"
|
||||
await self.new_session(name=session_name)
|
||||
|
||||
@@ -64,8 +64,9 @@ skill file the same way.
|
||||
(`*.py` / `**/*.py` fail with `invalid regex`), and a literal file/dir `path` \
|
||||
(no glob expansion, so `src/**/*.py` fails with `path does not exist`); there is \
|
||||
no file-type filter, so narrow with `path` and filter hits yourself.
|
||||
- Truncated results (any tool) carry a `truncate_token=...`; use `get_truncated` \
|
||||
with it to keep reading (chains through truncations).
|
||||
- A long tool result is stored whole and trimmed on each request with a \
|
||||
`document_id=...` marker; read the rest with `read_document` (line window) or \
|
||||
`search_document` (regex), no re-run.
|
||||
|
||||
### Commands
|
||||
- Prefer `run_argv` / `run_argv_batch` (argv lists, no shell) over `bash -c` or similar.
|
||||
@@ -75,7 +76,7 @@ confirm blocks the turn, so it costs time. Invoke the target command directly (a
|
||||
of wrapping it in `bash -c` / `python -c` / `node`, and reach for a dedicated tool \
|
||||
(`read_file` / `edit_*` / `vcs_*` / `fetch` / `fetch_local` / …) when one already does the job.
|
||||
- Several `run_argv` calls in one step may run in parallel; use `run_argv_batch` \
|
||||
for ordered pipelines (`pipe_out` / `mix_stderr` as needed).
|
||||
for ordered commands, with `then` / `else` / `pipe` continuations as needed.
|
||||
- Prefer `vcs_*` for status/diff/log/branch when enough.
|
||||
|
||||
### Network
|
||||
@@ -84,7 +85,7 @@ for ordered pipelines (`pipe_out` / `mix_stderr` as needed).
|
||||
(GET/POST/PUT/DELETE) from this host for localhost/LAN, APIs, custom headers, and \
|
||||
status/redirect inspection.
|
||||
- `offset` (0-based) skips leading body characters to page through a large body; \
|
||||
truncated bodies resume with `get_truncated` and are not re-requested.
|
||||
a long body is stored whole, so its `document_id` reads the rest with no new request.
|
||||
- Set `user_agent` (or a User-Agent header) on `fetch_local` when the remote expects a \
|
||||
specific client; otherwise a small default is used. Never rely on shell to spoof identity.
|
||||
- Private/loopback/LAN hosts need an explicit human policy allow (not skipped by YOLO); \
|
||||
@@ -151,6 +152,11 @@ class AgentConfig(Struct, omit_defaults=True):
|
||||
# into the agent system prompt when tools are on. false = ignore server text.
|
||||
mcp_instructions: bool = True
|
||||
max_tool_result_chars: int = 32_000
|
||||
# Storage ceiling for one tool result kept whole in its message. The wire cap
|
||||
# above trims a result on the request copy and names a document for the stored
|
||||
# remainder; a result above this ceiling keeps its head behind a notice saying
|
||||
# how much was dropped (the document cannot serve that tail).
|
||||
hard_max_tool_result_chars: int = 2_000_000
|
||||
parallel_tools: bool = True
|
||||
confirm_destructive: bool = True
|
||||
path_denylist: list[str] = field(default_factory=list)
|
||||
|
||||
@@ -4,7 +4,8 @@ A grant is one ``path → mode`` pair; its *source* is structural:
|
||||
|
||||
- :attr:`WorkspacePolicy.config_allow` — static TOML pre-allow (never prompts),
|
||||
- :attr:`SessionState.access_grants` — human-approved for this session,
|
||||
- :attr:`WorkspacePolicy.yolo_allow` — ``--yes`` / YOLO auto-approval, process only.
|
||||
- :attr:`WorkspacePolicy.yolo_allow` — process only: a host that approved
|
||||
without keeping the grant (``--yes`` does **not** answer this prompt).
|
||||
|
||||
``resolve_path`` consults all three. Grants are a policy gate for path-resolving
|
||||
tools, **not** a sandbox: an ``exec`` grant (or any command) can reach the rest
|
||||
@@ -16,7 +17,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.agent import Denial, ToolTag, tool
|
||||
from plyngent.tools.context import get_session, require_session
|
||||
from plyngent.tools.workspace import (
|
||||
AccessMode,
|
||||
@@ -51,7 +52,7 @@ class AccessDecision(NamedTuple):
|
||||
persist: bool = True
|
||||
|
||||
|
||||
type DirectoryAccessConfirmHook = Callable[[Path, AccessMode, str, float], AccessDecision | str | None]
|
||||
type DirectoryAccessConfirmHook = Callable[[Path, AccessMode, str, float], AccessDecision | str | Denial | None]
|
||||
|
||||
|
||||
def get_directory_access_confirm_hook(instance: InstanceState | None = None) -> DirectoryAccessConfirmHook | None:
|
||||
@@ -61,9 +62,9 @@ def get_directory_access_confirm_hook(instance: InstanceState | None = None) ->
|
||||
if hook is None or not callable(hook):
|
||||
return None
|
||||
|
||||
def _as_hook(path: Path, mode: AccessMode, reason: str, timeout: float) -> AccessDecision | str | None:
|
||||
def _as_hook(path: Path, mode: AccessMode, reason: str, timeout: float) -> AccessDecision | str | Denial | None:
|
||||
result: object = hook(path, mode, reason, timeout)
|
||||
if isinstance(result, AccessDecision | str):
|
||||
if isinstance(result, AccessDecision | str | Denial):
|
||||
return result
|
||||
return None
|
||||
|
||||
@@ -231,8 +232,12 @@ def _precheck_target(target: Path, requested: AccessMode) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _apply_decision(target: Path, decision: AccessDecision | str | None, timeout: float) -> str:
|
||||
"""Record an approved grant or format the denial for the model."""
|
||||
def _apply_decision(target: Path, decision: AccessDecision | str | Denial | None) -> str:
|
||||
"""Record an approved grant or format the denial for the model.
|
||||
|
||||
The prompt says whether the human declined or the clock ran out, so a timeout
|
||||
is never reported as a decision.
|
||||
"""
|
||||
if isinstance(decision, AccessDecision):
|
||||
if decision.persist and get_session() is not None:
|
||||
_ = grant_session_access(target, decision.mode)
|
||||
@@ -244,9 +249,10 @@ def _apply_decision(target: Path, decision: AccessDecision | str | None, timeout
|
||||
f"access granted: {target} ({decision.mode.name.lower()}, {scope})\n"
|
||||
"note: the path denylist still applies; the human can revoke with /grants"
|
||||
)
|
||||
if isinstance(decision, str) and decision.strip():
|
||||
return f"error: access to {target} denied by user; user comment: {decision.strip()}"
|
||||
return f"error: access to {target} denied (declined or timed out after {timeout:g}s)"
|
||||
denial = Denial.from_answer(decision)
|
||||
text = f"error: access to {target} denied ({denial.clause()})"
|
||||
comment = denial.user_comment
|
||||
return f"{text}; user comment: {comment}" if comment else text
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.SESSION_STATE)
|
||||
@@ -257,8 +263,10 @@ async def request_directory_access(path: str, reason: str = "", mode: str = "rea
|
||||
workspace root``, passing the ``mode`` that error names. ``mode`` is ``read``
|
||||
(inspect files), ``write`` (also modify files) or ``exec`` (also use it as
|
||||
``run_argv`` / PTY cwd); the human may approve a different level. Approval
|
||||
lasts for the session (or for this process when ``--yes`` auto-approves).
|
||||
Paths matching the configured denylist can never be granted.
|
||||
lasts for the session (or for this process when the host answers without
|
||||
persisting); ``--yes`` / YOLO mode does not answer this prompt, and a
|
||||
non-interactive run denies. Paths matching the configured denylist can never
|
||||
be granted.
|
||||
"""
|
||||
requested = parse_access_mode(mode)
|
||||
if requested is None:
|
||||
@@ -278,7 +286,7 @@ async def request_directory_access(path: str, reason: str = "", mode: str = "rea
|
||||
decision = hook(target, requested, reason, timeout)
|
||||
except Exception as exc: # noqa: BLE001 — surface confirm failures to the model
|
||||
return f"error: access confirm for {target} failed: {exc}"
|
||||
return _apply_decision(target, decision, timeout)
|
||||
return _apply_decision(target, decision)
|
||||
|
||||
|
||||
ACCESS_TOOLS = [
|
||||
|
||||
@@ -14,20 +14,9 @@ tools themselves stay free of unnecessary-isinstance warnings.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_SHAPES: dict[type, str] = {
|
||||
str: "a string",
|
||||
list: "an array",
|
||||
dict: "an object",
|
||||
bool: "a boolean",
|
||||
int: "a number",
|
||||
float: "a number",
|
||||
type(None): "null",
|
||||
}
|
||||
from plyngent.tools.shape import shape_of
|
||||
|
||||
|
||||
def shape_of(value: object) -> str:
|
||||
"""Model-facing name for a decoded JSON value (``a string``, ``an array``…)."""
|
||||
return _SHAPES.get(type(value), type(value).__name__)
|
||||
__all__ = ["first_error", "list_error", "number_arg", "shape_of", "string_error"]
|
||||
|
||||
|
||||
def first_error(*errors: str | None) -> str | None:
|
||||
|
||||
@@ -14,6 +14,7 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.agent.todo_stack import TodoStack
|
||||
from plyngent.agent.tools import Approval
|
||||
from plyngent.skills import SkillStore
|
||||
from plyngent.tools.workspace import AccessMode, WorkspacePolicy
|
||||
|
||||
@@ -150,6 +151,34 @@ def bind_session(state: SessionState | None) -> Generator[SessionState | None]:
|
||||
|
||||
_read_only: ContextVar[bool] = ContextVar("plyngent_read_only_context", default=False)
|
||||
|
||||
# Approvals a handler records for the call it is answering. The registry binds a
|
||||
# fresh list around one call and drains it into the result's first line.
|
||||
_approvals: ContextVar[list[Approval] | None] = ContextVar("plyngent_tool_approvals", default=None)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def collect_approvals() -> Generator[list[Approval]]:
|
||||
"""Collect what the running tool call records about how it was approved."""
|
||||
sink: list[Approval] = []
|
||||
token = _approvals.set(sink)
|
||||
try:
|
||||
yield sink
|
||||
finally:
|
||||
_approvals.reset(token)
|
||||
|
||||
|
||||
def record_approval(approval: Approval) -> None:
|
||||
"""Record how the running call was approved (no-op outside a tool call).
|
||||
|
||||
A handler that asks the human for something policy-level — a denylisted
|
||||
command, a skill write, a private host — says so here, so the model reads the
|
||||
answer in the result it is about to see. The registry turns the records into
|
||||
the single ``[approval]`` line that leads the tool result.
|
||||
"""
|
||||
sink = _approvals.get()
|
||||
if sink is not None:
|
||||
sink.append(approval)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def read_only_context() -> Generator[None]:
|
||||
|
||||
@@ -10,7 +10,7 @@ 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
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
|
||||
|
||||
def _as_argv(args: Mapping[str, object], *, key: str = "command") -> list[str] | None:
|
||||
@@ -188,6 +188,32 @@ def _batch_step_argv(item: object) -> list[str] | None:
|
||||
return parts or None
|
||||
|
||||
|
||||
def _batch_nodes(value: object) -> list[dict[str, object]]:
|
||||
"""Nodes of one continuation: a single node, or a list of them."""
|
||||
if isinstance(value, dict):
|
||||
return [cast("dict[str, object]", value)]
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [cast("dict[str, object]", item) for item in cast("list[object]", value) if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _walk_batch_argv(value: object, where: str) -> Iterator[tuple[str, list[str]]]:
|
||||
"""Every command of a batch tree, in the order it can run, with its path.
|
||||
|
||||
``then`` / ``else`` / ``pipe`` hold the continuations, so a command hiding in
|
||||
one is reviewed exactly like a top-level step.
|
||||
"""
|
||||
for index, node in enumerate(_batch_nodes(value)):
|
||||
label = f"{where}[{index}]"
|
||||
argv = _batch_step_argv(node)
|
||||
if argv is not None:
|
||||
yield label, argv
|
||||
for branch in ("then", "else", "pipe"):
|
||||
child = node.get(branch)
|
||||
if child is not None:
|
||||
yield from _walk_batch_argv(child, f"{label}.{branch}")
|
||||
|
||||
|
||||
def _run_argv_batch_reason(args: Mapping[str, object]) -> DangerReason | None:
|
||||
"""One confirm for the whole batch if any step runs code or detaches.
|
||||
|
||||
@@ -203,12 +229,11 @@ def _run_argv_batch_reason(args: Mapping[str, object]) -> DangerReason | None:
|
||||
raw = loaded
|
||||
if not isinstance(raw, list):
|
||||
return None
|
||||
risky = [
|
||||
reason
|
||||
for index, item in enumerate(cast("list[object]", raw))
|
||||
if (argv := _batch_step_argv(item)) is not None
|
||||
and (reason := _command_reason(argv, via=f"run_argv_batch[{index}]")) is not None
|
||||
]
|
||||
risky: list[DangerReason] = []
|
||||
for label, argv in _walk_batch_argv(cast("list[object]", raw), "run_argv_batch"):
|
||||
reason = _command_reason(argv, via=label)
|
||||
if reason is not None:
|
||||
risky.append(reason)
|
||||
if not risky:
|
||||
return None
|
||||
header = f"run_argv_batch: {len(risky)} risky step(s) (review before allow)"
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Documents: addressable views over tool-result messages.
|
||||
|
||||
A tool result is stored whole in its own ``ToolChatMessage``. When the agent's
|
||||
request-prep pass caps a long result, the marker it leaves names a
|
||||
``document_id`` (the message's ``tool_call_id``); the model reads the rest with
|
||||
the document tools. The body stays the message itself, so a document survives a
|
||||
process restart with the session that holds it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from msgspec import UNSET
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, ToolChatMessage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Document:
|
||||
"""One tool-result message exposed as a document."""
|
||||
|
||||
document_id: str
|
||||
text: str
|
||||
tool_name: str | None
|
||||
index: int
|
||||
|
||||
@property
|
||||
def chars(self) -> int:
|
||||
return len(self.text)
|
||||
|
||||
@property
|
||||
def lines(self) -> int:
|
||||
return len(self.text.splitlines())
|
||||
|
||||
|
||||
def document_marker(document_id: str, shown: int, total: int, *, stored_cut: bool = False) -> str:
|
||||
"""The request-prep cap hint naming the document that holds the remainder.
|
||||
|
||||
*total* counts what the stored message holds, so it is the end of what the
|
||||
document can serve. ``stored_cut`` says the storage ceiling already dropped a
|
||||
tail, which the document cannot serve either.
|
||||
"""
|
||||
cut = (
|
||||
" The stored body was itself cut at the storage ceiling, so part of the output is not in the document."
|
||||
if stored_cut
|
||||
else ""
|
||||
)
|
||||
return (
|
||||
f"\n[Truncated: {shown} of {total} chars shown.{cut} document_id={document_id}. "
|
||||
f"Continue with read_document(document_id, offset=...) or "
|
||||
f"search_document(document_id, pattern=...).]"
|
||||
)
|
||||
|
||||
|
||||
def _tool_names(messages: Sequence[AnyChatMessage]) -> dict[str, str]:
|
||||
"""Map each tool-call id to the tool name the assistant asked for."""
|
||||
names: dict[str, str] = {}
|
||||
for message in messages:
|
||||
if not isinstance(message, AssistantChatMessage):
|
||||
continue
|
||||
tool_calls = message.tool_calls
|
||||
if tool_calls is UNSET or not tool_calls:
|
||||
continue
|
||||
for call in tool_calls:
|
||||
function = getattr(call, "function", None)
|
||||
name = getattr(function, "name", None) if function is not None else None
|
||||
call_id = getattr(call, "id", None)
|
||||
if isinstance(call_id, str) and isinstance(name, str):
|
||||
names[call_id] = name
|
||||
return names
|
||||
|
||||
|
||||
class DocumentStore:
|
||||
"""Resolve document ids to the tool-result messages that carry their body.
|
||||
|
||||
*resolver* returns the live message history (the agent's own list), so the
|
||||
store reflects the session as it grows and reads whatever a resume reloaded
|
||||
from the durable store.
|
||||
"""
|
||||
|
||||
_resolver: Callable[[], Sequence[AnyChatMessage]]
|
||||
|
||||
def __init__(self, resolver: Callable[[], Sequence[AnyChatMessage]]) -> None:
|
||||
self._resolver = resolver
|
||||
|
||||
def _documents(self) -> list[Document]:
|
||||
messages = self._resolver()
|
||||
names = _tool_names(messages)
|
||||
documents: list[Document] = []
|
||||
for message in messages:
|
||||
if not isinstance(message, ToolChatMessage):
|
||||
continue
|
||||
documents.append(
|
||||
Document(
|
||||
document_id=message.tool_call_id,
|
||||
text=message.content,
|
||||
tool_name=names.get(message.tool_call_id),
|
||||
index=len(documents),
|
||||
)
|
||||
)
|
||||
return documents
|
||||
|
||||
def get(self, document_id: str) -> Document | None:
|
||||
for document in self._documents():
|
||||
if document.document_id == document_id:
|
||||
return document
|
||||
return None
|
||||
|
||||
def list(self) -> list[Document]:
|
||||
return self._documents()
|
||||
|
||||
|
||||
def get_document_store() -> DocumentStore | None:
|
||||
"""The host-bound document store for the current tool context, if any."""
|
||||
from plyngent.tools.context import get_instance
|
||||
|
||||
instance = get_instance()
|
||||
if instance is None:
|
||||
return None
|
||||
store = instance.extras.get("documents")
|
||||
return store if isinstance(store, DocumentStore) else None
|
||||
@@ -1,5 +1,9 @@
|
||||
from plyngent.tools.temp_workspace import new_temporary_workspace as new_temporary_workspace
|
||||
|
||||
from .document import document_info as document_info
|
||||
from .document import list_documents as list_documents
|
||||
from .document import read_document as read_document
|
||||
from .document import search_document as search_document
|
||||
from .edit_lineno import edit_lineno as edit_lineno
|
||||
from .edit_replace import edit_replace as edit_replace
|
||||
from .fs_ops import copy_path as copy_path
|
||||
@@ -29,4 +33,8 @@ FILE_TOOLS = [
|
||||
delete_path,
|
||||
new_temporary_workspace,
|
||||
get_truncated,
|
||||
read_document,
|
||||
search_document,
|
||||
document_info,
|
||||
list_documents,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Document tools: read and search the tool results the request-prep pass capped.
|
||||
|
||||
A long tool result is stored whole in its tool message and capped on the wire
|
||||
with a ``document_id`` marker. These tools resolve that id back to the message
|
||||
body, so reading and searching a capped result need no re-run of the tool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
|
||||
from plyngent.tools.documents import Document, get_document_store
|
||||
from plyngent.tools.file.read import format_with_lineno
|
||||
|
||||
DEFAULT_MAX_MATCHES = 100
|
||||
DEFAULT_LINE_PREVIEW = 200
|
||||
|
||||
_MISSING_STORE = "error: documents are in scope only while a chat session holds tool results"
|
||||
_UNKNOWN = "error: unknown document {document_id!r} (see list_documents)"
|
||||
|
||||
|
||||
def _resolve(document_id: str) -> Document | str:
|
||||
"""The document for *document_id*, or a model-facing error string."""
|
||||
store = get_document_store()
|
||||
if store is None:
|
||||
return _MISSING_STORE
|
||||
document = store.get(document_id)
|
||||
if document is None:
|
||||
return _UNKNOWN.format(document_id=document_id)
|
||||
return document
|
||||
|
||||
|
||||
def _cap_body(body: str, max_chars: int) -> tuple[str, bool]:
|
||||
if max_chars < 1 or len(body) <= max_chars:
|
||||
return body, False
|
||||
return body[:max_chars], True
|
||||
|
||||
|
||||
def _preview(line: str) -> str:
|
||||
return line if len(line) <= DEFAULT_LINE_PREVIEW else line[:DEFAULT_LINE_PREVIEW] + "…"
|
||||
|
||||
|
||||
def _compile_pattern(pattern: str, *, case_insensitive: bool) -> re.Pattern[str] | str:
|
||||
if not pattern:
|
||||
return "error: pattern must not be empty"
|
||||
flags = re.MULTILINE
|
||||
if case_insensitive:
|
||||
flags |= re.IGNORECASE
|
||||
try:
|
||||
return re.compile(pattern, flags)
|
||||
except re.error as exc:
|
||||
return f"error: invalid regex: {exc}"
|
||||
|
||||
|
||||
def _collect_hits(lines: list[str], regex: re.Pattern[str], max_matches: int) -> list[str]:
|
||||
hits: list[str] = []
|
||||
for number, line in enumerate(lines, start=1):
|
||||
if regex.search(line) is None:
|
||||
continue
|
||||
hits.append(f"L{number}: {_preview(line)}")
|
||||
if len(hits) >= max_matches:
|
||||
break
|
||||
return hits
|
||||
|
||||
|
||||
def _search_with_context(
|
||||
lines: list[str],
|
||||
regex: re.Pattern[str],
|
||||
max_matches: int,
|
||||
context_lines: int,
|
||||
) -> str:
|
||||
matches = [number for number, line in enumerate(lines, start=1) if regex.search(line) is not None]
|
||||
if not matches:
|
||||
return "(no matches)"
|
||||
truncated = len(matches) > max_matches
|
||||
ranges: list[tuple[int, int]] = []
|
||||
for number in matches[:max_matches]:
|
||||
low = max(1, number - context_lines)
|
||||
high = min(len(lines), number + context_lines)
|
||||
if ranges and low <= ranges[-1][1] + 1:
|
||||
ranges[-1] = (ranges[-1][0], max(ranges[-1][1], high))
|
||||
else:
|
||||
ranges.append((low, high))
|
||||
blocks = [
|
||||
"\n".join(f"L{number}: {_preview(lines[number - 1])}" for number in range(low, high + 1))
|
||||
for low, high in ranges
|
||||
]
|
||||
body = "\n--\n".join(blocks)
|
||||
if truncated:
|
||||
body += f"\n...[truncated at {max_matches} matches]"
|
||||
return body
|
||||
|
||||
|
||||
def _search(text: str, regex: re.Pattern[str], max_matches: int, context_lines: int) -> str:
|
||||
lines = text.splitlines()
|
||||
if context_lines > 0:
|
||||
return _search_with_context(lines, regex, max_matches, context_lines)
|
||||
hits = _collect_hits(lines, regex, max_matches)
|
||||
if not hits:
|
||||
return "(no matches)"
|
||||
body = "\n".join(hits)
|
||||
if len(hits) >= max_matches:
|
||||
body += f"\n...[truncated at {max_matches} matches]"
|
||||
return body
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||
async def read_document(
|
||||
document_id: str,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int | None = None,
|
||||
with_lineno: bool = False,
|
||||
max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||
) -> str:
|
||||
"""Read a capped tool result as a document, one window at a time.
|
||||
|
||||
A long tool result is stored whole in its tool message and capped on the wire
|
||||
with a ``document_id`` marker; pass that id here to read past the cap.
|
||||
``offset`` is a 0-based line start (0 = first line), ``limit`` a line count
|
||||
(None = the rest), and ``with_lineno`` prefixes each line with its 1-based
|
||||
number (`` N|…``). When the window is cut by ``max_chars`` the reply names
|
||||
the next ``offset`` to send. ``list_documents`` lists the session's ids.
|
||||
"""
|
||||
document = _resolve(document_id)
|
||||
if isinstance(document, str):
|
||||
return document
|
||||
if offset < 0:
|
||||
return "error: offset must be >= 0"
|
||||
lines = document.text.splitlines(keepends=True)
|
||||
if offset >= len(lines):
|
||||
return ""
|
||||
end = len(lines) if limit is None else min(len(lines), offset + limit)
|
||||
slice_lines = lines[offset:end]
|
||||
body = format_with_lineno(slice_lines, start_lineno=offset + 1) if with_lineno else "".join(slice_lines)
|
||||
body, cut = _cap_body(body, max_chars)
|
||||
shown_lines = max(1, body.count("\n")) if cut else len(slice_lines)
|
||||
next_offset = offset + shown_lines
|
||||
if next_offset < len(lines):
|
||||
separator = "" if body.endswith("\n") else "\n"
|
||||
body = f"{body}{separator}[... more: next offset={next_offset}]"
|
||||
if with_lineno:
|
||||
return body
|
||||
return f"L{offset + 1}-{next_offset}\n{body}"
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||
async def search_document(
|
||||
document_id: str,
|
||||
pattern: str,
|
||||
*,
|
||||
case_insensitive: bool = False,
|
||||
max_matches: int = DEFAULT_MAX_MATCHES,
|
||||
context_lines: int = 0,
|
||||
) -> str:
|
||||
"""Search a document's lines with a Python ``re`` regex, per document.
|
||||
|
||||
Like ``regex_files`` but over one tool result. ``pattern`` is a Python regex
|
||||
matched per line (``re.MULTILINE``), not a glob. Returns ``L{line}: {content}``
|
||||
hits, long lines clipped with ``…``, capped at ``max_matches`` (a
|
||||
``...[truncated at N matches]`` marker follows a cut). ``context_lines`` adds
|
||||
that many lines around each hit, blocks joined with ``--``. ``list_documents``
|
||||
lists the session's ids.
|
||||
"""
|
||||
if max_matches < 1:
|
||||
return "error: max_matches must be >= 1"
|
||||
if context_lines < 0:
|
||||
return "error: context_lines must be >= 0"
|
||||
document = _resolve(document_id)
|
||||
if isinstance(document, str):
|
||||
return document
|
||||
regex = _compile_pattern(pattern, case_insensitive=case_insensitive)
|
||||
if isinstance(regex, str):
|
||||
return regex
|
||||
return _search(document.text, regex, max_matches, context_lines)
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||
async def document_info(document_id: str) -> str:
|
||||
"""Describe one document: source tool, size, and first/last line previews."""
|
||||
document = _resolve(document_id)
|
||||
if isinstance(document, str):
|
||||
return document
|
||||
lines = document.text.splitlines()
|
||||
first = _preview(lines[0]) if lines else ""
|
||||
last = _preview(lines[-1]) if lines else ""
|
||||
return "\n".join(
|
||||
[
|
||||
f"document_id={document.document_id}",
|
||||
f"tool={document.tool_name or '(unknown)'}",
|
||||
f"chars={document.chars}",
|
||||
f"lines={document.lines}",
|
||||
f"first={first}",
|
||||
f"last={last}",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||
async def list_documents() -> str:
|
||||
"""List the session's documents (capped tool results) with their ids and sizes."""
|
||||
store = get_document_store()
|
||||
if store is None:
|
||||
return _MISSING_STORE
|
||||
documents = store.list()
|
||||
if not documents:
|
||||
return "(no documents)"
|
||||
rows: list[str] = []
|
||||
for document in documents:
|
||||
first_line = document.text.splitlines()[0] if document.text else ""
|
||||
rows.append(
|
||||
f"{document.index}. document_id={document.document_id} tool={document.tool_name or '(unknown)'} "
|
||||
f"chars={document.chars} lines={document.lines} | {_preview(first_line)}"
|
||||
)
|
||||
return "\n".join(rows)
|
||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import ToolTag, lineno_read_lines, lineno_read_mtime, tool
|
||||
from plyngent.tools.shape import shape_of
|
||||
from plyngent.tools.workspace import AccessMode, resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
# How many unread line numbers to preview in the error message.
|
||||
@@ -15,6 +17,28 @@ _UNREAD_PREVIEW = 8
|
||||
_READ_HINT_MARGIN = 3
|
||||
|
||||
|
||||
def _range_shape_error(start_line: object, end_line: object) -> str | None:
|
||||
"""The two range checks that need nothing but the two numbers."""
|
||||
if not isinstance(start_line, int) or not isinstance(end_line, int):
|
||||
return None # the schema declares integers; a bad pair stays the handler's to answer
|
||||
if start_line < 1:
|
||||
return "error: start_line must be >= 1"
|
||||
if end_line < start_line:
|
||||
return "error: end_line must be >= start_line"
|
||||
return None
|
||||
|
||||
|
||||
def edit_lineno_shape(args: Mapping[str, object]) -> str | None:
|
||||
"""Answer a range that cannot be applied, before anyone is asked about it."""
|
||||
shape_error = _range_shape_error(args.get("start_line"), args.get("end_line"))
|
||||
if shape_error is not None:
|
||||
return shape_error
|
||||
content = args.get("new_content")
|
||||
if content is not None and not isinstance(content, str):
|
||||
return f"error: `edit_lineno` expects `new_content` as a string, got {shape_of(content)}"
|
||||
return None
|
||||
|
||||
|
||||
def _detect_newline(lines: list[str]) -> str:
|
||||
for sample in lines:
|
||||
if sample.endswith("\r\n"):
|
||||
@@ -43,10 +67,9 @@ def _to_keepends(new_content: str, newline: str, *, force_trailing: bool) -> lis
|
||||
|
||||
|
||||
def _validate_range(start_line: int, end_line: int, n: int) -> str | None:
|
||||
if start_line < 1:
|
||||
return "error: start_line must be >= 1"
|
||||
if end_line < start_line:
|
||||
return "error: end_line must be >= start_line"
|
||||
shape_error = _range_shape_error(start_line, end_line)
|
||||
if shape_error is not None:
|
||||
return shape_error
|
||||
if start_line > n + 1:
|
||||
return f"error: start_line {start_line} past end of file ({n} lines)"
|
||||
if start_line == n + 1 and end_line != start_line:
|
||||
@@ -124,7 +147,7 @@ def _replace_range(
|
||||
)
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("path",))
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("path",), validate=edit_lineno_shape)
|
||||
async def edit_lineno(path: str, start_line: int, end_line: int, new_content: str) -> str:
|
||||
"""Replace lines ``start_line``..``end_line`` (1-based, inclusive) in a file.
|
||||
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.tools.shape import shape_of
|
||||
from plyngent.tools.workspace import AccessMode, resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
def edit_replace_shape(args: Mapping[str, object]) -> str | None:
|
||||
"""Answer a replacement that cannot apply, before anyone is asked about it."""
|
||||
old_string = args.get("old_string")
|
||||
if isinstance(old_string, str) and not old_string:
|
||||
return "error: old_string must not be empty"
|
||||
if not isinstance(old_string, str) and old_string is not None:
|
||||
return f"error: `edit_replace` expects `old_string` as a string, got {shape_of(old_string)}"
|
||||
limit = args.get("max_replaces")
|
||||
if isinstance(limit, int) and not isinstance(limit, bool) and limit < 1:
|
||||
return "error: max_replaces must be >= 1"
|
||||
return None
|
||||
|
||||
|
||||
def _count_non_overlapping(text: str, needle: str) -> int:
|
||||
"""Count left-to-right non-overlapping matches (same as ``str.replace``)."""
|
||||
@@ -48,7 +67,7 @@ def _success_message(
|
||||
)
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("path",))
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("path",), validate=edit_replace_shape)
|
||||
async def edit_replace(path: str, old_string: str, new_string: str, max_replaces: int = 1) -> str:
|
||||
"""Replace occurrences of ``old_string`` with ``new_string`` in a file.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from plyngent.tools.workspace import AccessMode, resolve_path
|
||||
_LINENO_WIDTH = 6
|
||||
|
||||
|
||||
def _format_with_lineno(lines: list[str], *, start_lineno: int) -> str:
|
||||
def format_with_lineno(lines: list[str], *, start_lineno: int) -> str:
|
||||
"""Prefix each line with a 1-based absolute line number (``edit_lineno`` style)."""
|
||||
out: list[str] = []
|
||||
for index, line in enumerate(lines):
|
||||
@@ -94,7 +94,7 @@ async def read_file(
|
||||
if start >= len(lines):
|
||||
return ""
|
||||
slice_lines = lines[start:end]
|
||||
body = _format_with_lineno(slice_lines, start_lineno=start + 1) if with_lineno else "".join(slice_lines)
|
||||
body = format_with_lineno(slice_lines, start_lineno=start + 1) if with_lineno else "".join(slice_lines)
|
||||
char_start = len("".join(lines[:start]))
|
||||
if max_chars >= 1:
|
||||
body, token = truncate_with_token(
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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
|
||||
@@ -9,7 +11,6 @@ from plyngent.tools.net.client import FetchResult, http_fetch
|
||||
from plyngent.tools.net.policy import (
|
||||
DEFAULT_MAX_BODY_CHARS_IN,
|
||||
DEFAULT_MAX_BYTES,
|
||||
DEFAULT_MAX_CHARS,
|
||||
DEFAULT_MAX_REDIRECTS,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
FetchPolicyError,
|
||||
@@ -18,9 +19,36 @@ from plyngent.tools.net.policy import (
|
||||
parse_fetch_url,
|
||||
soft_confirm_reason,
|
||||
)
|
||||
from plyngent.tools.truncate_token import truncate_to_memory
|
||||
from plyngent.tools.shape import shape_of, timeout_shape_error
|
||||
from plyngent.tools.workspace import image_input_refusal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
def fetch_local_shape(args: Mapping[str, object]) -> str | None:
|
||||
"""Answer a request that cannot be built, before anyone is asked about it.
|
||||
|
||||
The verb and the URL are checked here (both are the request itself, and
|
||||
``parse_fetch_url`` touches nothing but the string); a host that needs a
|
||||
human grant still asks in the handler, once the call can actually run.
|
||||
"""
|
||||
url = args.get("url")
|
||||
if not isinstance(url, str):
|
||||
return f"error: `fetch_local` expects `url` as a string, got {shape_of(url)}"
|
||||
method = args.get("method", "GET")
|
||||
if not isinstance(method, str):
|
||||
return f"error: `fetch_local` expects `method` as a string, got {shape_of(method)}"
|
||||
timeout_error = timeout_shape_error(args.get("timeout_seconds"))
|
||||
if timeout_error is not None:
|
||||
return f"error: `fetch_local` {timeout_error}"
|
||||
try:
|
||||
_ = normalize_method(method)
|
||||
_ = parse_fetch_url(url)
|
||||
except FetchPolicyError as exc:
|
||||
return f"error: {exc}"
|
||||
return None
|
||||
|
||||
|
||||
def format_fetch_result(
|
||||
*,
|
||||
@@ -35,15 +63,13 @@ def format_fetch_result(
|
||||
security: str,
|
||||
warnings: list[str],
|
||||
body_kind: str,
|
||||
max_chars: int,
|
||||
offset: int = 0,
|
||||
) -> str:
|
||||
start = max(0, offset)
|
||||
# The body is already in hand, so the remainder is kept in memory rather than
|
||||
# re-requested: resuming a fetch must not cost another round trip (latency)
|
||||
# nor re-hit the remote (rate limits / risk control).
|
||||
body, token = truncate_to_memory(body_text[start:], max_chars)
|
||||
char_truncated = token is not None
|
||||
# The body is kept whole in the tool message (the request-prep pass trims it
|
||||
# on the wire and names its document), so resuming reads the message rather
|
||||
# than re-hitting the remote.
|
||||
body = body_text[start:]
|
||||
warn_line = "; ".join(warnings) if warnings else ""
|
||||
parts = [
|
||||
f"status={status}",
|
||||
@@ -52,7 +78,7 @@ def format_fetch_result(
|
||||
f"content_type={content_type}",
|
||||
f"body_kind={body_kind}",
|
||||
f"bytes={body_bytes}",
|
||||
f"truncated={'true' if truncated or char_truncated else 'false'}",
|
||||
f"truncated={'true' if truncated else 'false'}",
|
||||
f"redirects={redirects}",
|
||||
f"security={security}",
|
||||
]
|
||||
@@ -63,7 +89,10 @@ def format_fetch_result(
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO | ToolTag.TRUSTABLE | ToolTag.READ_ONLY)
|
||||
@tool(
|
||||
tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO | ToolTag.TRUSTABLE | ToolTag.READ_ONLY,
|
||||
validate=fetch_local_shape,
|
||||
)
|
||||
async def fetch_local(
|
||||
url: str,
|
||||
*,
|
||||
@@ -73,19 +102,18 @@ async def fetch_local(
|
||||
user_agent: str | None = None,
|
||||
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
max_bytes: int = DEFAULT_MAX_BYTES,
|
||||
max_chars: int = DEFAULT_MAX_CHARS,
|
||||
max_redirects: int = DEFAULT_MAX_REDIRECTS,
|
||||
follow_redirects: bool = True,
|
||||
allow_http_downgrade: bool = False,
|
||||
offset: int = 0,
|
||||
) -> str | ToolOutput:
|
||||
"""HTTP request (GET/POST/PUT/DELETE); return status, metadata, and truncated body.
|
||||
"""HTTP request (GET/POST/PUT/DELETE); return status, metadata, and the body.
|
||||
|
||||
Use for public docs/APIs or (after human policy allow) local/LAN servers.
|
||||
``offset`` skips that many leading characters of the body (0-based), for
|
||||
paging through a body larger than ``max_chars``; a body truncated by
|
||||
``max_chars`` keeps its remainder in memory instead, resumable via its
|
||||
``truncate_token`` without another request.
|
||||
paging through a body the wire cap trimmed; a trimmed body keeps its
|
||||
remainder in the tool message, so its ``document_id`` reads the rest with no
|
||||
new request.
|
||||
``user_agent`` sets User-Agent when provided and takes precedence over a
|
||||
User-Agent entry in ``headers``. A headers-only User-Agent is kept as-is.
|
||||
When both omit UA, a small default is used.
|
||||
@@ -138,7 +166,6 @@ async def fetch_local(
|
||||
security=result.security,
|
||||
warnings=result.warnings,
|
||||
body_kind=result.body_kind,
|
||||
max_chars=max_chars,
|
||||
offset=offset,
|
||||
)
|
||||
return _attach_image(text, result)
|
||||
|
||||
@@ -13,8 +13,6 @@ import inspect
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent.tools import ToolDefinition, ToolTag, schema_from_callable
|
||||
from plyngent.tools.net.policy import DEFAULT_MAX_CHARS
|
||||
from plyngent.tools.truncate_token import truncate_to_memory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from plyngent.lmproto.deepseek.webtools import DeepseekWebToolsClient
|
||||
@@ -27,31 +25,28 @@ def format_page_result(
|
||||
title: str,
|
||||
url: str,
|
||||
content: str,
|
||||
max_chars: int,
|
||||
offset: int = 0,
|
||||
) -> str:
|
||||
"""Render a fetched page the way the other structured tool results read."""
|
||||
start = max(0, offset)
|
||||
body, token = truncate_to_memory(content[start:], max_chars)
|
||||
# The page is kept whole in the tool message (the request-prep pass trims it
|
||||
# on the wire and names its document), so neither a re-fetch nor a charge
|
||||
# follows a read of the rest.
|
||||
body = content[start:]
|
||||
parts = [
|
||||
f"title={' '.join(title.split()) or '(untitled)'}",
|
||||
f"url={url}",
|
||||
f"chars={len(content)}",
|
||||
f"truncated={'true' if token is not None else 'false'}",
|
||||
"--- content ---",
|
||||
body,
|
||||
]
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def build_fetch_tool(
|
||||
client: DeepseekWebToolsClient,
|
||||
*,
|
||||
default_max_chars: int = DEFAULT_MAX_CHARS,
|
||||
) -> ToolDefinition:
|
||||
def build_fetch_tool(client: DeepseekWebToolsClient) -> ToolDefinition:
|
||||
"""A ``fetch`` definition backed by DeepSeek's Fetch API."""
|
||||
|
||||
async def fetch(url: str, offset: int = 0, max_chars: int = default_max_chars) -> str:
|
||||
async def fetch(url: str, offset: int = 0) -> str:
|
||||
"""Read a public web page as Markdown through DeepSeek's Fetch API.
|
||||
|
||||
Use for public articles and docs when clean Markdown is enough. The page
|
||||
@@ -60,9 +55,9 @@ def build_fetch_tool(
|
||||
when you need the raw HTTP status/headers. A successful fetch is billed
|
||||
per request (failures are not).
|
||||
|
||||
``offset`` (0-based) skips leading characters of the content. A body cut
|
||||
by ``max_chars`` keeps its remainder in memory, resumable via its
|
||||
``truncate_token`` without another request (and without another charge).
|
||||
``offset`` (0-based) skips leading characters of the content. The page is
|
||||
kept whole in the tool message, so a trimmed ``document_id`` reads the
|
||||
rest with no new request (and no new charge).
|
||||
"""
|
||||
try:
|
||||
page = await client.fetch(url)
|
||||
@@ -72,7 +67,6 @@ def build_fetch_tool(
|
||||
title=page.title,
|
||||
url=url,
|
||||
content=page.content,
|
||||
max_chars=max_chars,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,10 +10,11 @@ from plyngent.tools.workspace import WorkspaceError, require_bound_instance
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
from plyngent.agent.tools import Denial
|
||||
from plyngent.tools.context import InstanceState
|
||||
|
||||
# (host, port, url, timeout_seconds) -> True to allow for this process/instance.
|
||||
type FetchPolicyConfirmHook = Callable[[str, int, str, float], bool]
|
||||
type FetchPolicyConfirmHook = Callable[[str, int, str, float], bool | Denial]
|
||||
|
||||
|
||||
def get_private_grants(instance: InstanceState | None = None) -> set[str]:
|
||||
@@ -48,8 +49,14 @@ def get_fetch_policy_confirm_hook(instance: InstanceState | None = None) -> Fetc
|
||||
if hook is None or not callable(hook):
|
||||
return None
|
||||
|
||||
def _as_hook(host: str, port: int, url: str, timeout_seconds: float) -> bool:
|
||||
return bool(hook(host, port, url, timeout_seconds))
|
||||
def _as_hook(host: str, port: int, url: str, timeout_seconds: float) -> bool | Denial:
|
||||
from plyngent.agent import Denial as DenialAnswer
|
||||
|
||||
answer: object = hook(host, port, url, timeout_seconds)
|
||||
# Pass the reason through: only an explicit True allows.
|
||||
if answer is True or isinstance(answer, DenialAnswer):
|
||||
return answer
|
||||
return False
|
||||
|
||||
return _as_hook
|
||||
|
||||
@@ -89,7 +96,11 @@ async def ensure_host_allowed(
|
||||
raise FetchPolicyError(msg)
|
||||
|
||||
# PRIVATE
|
||||
from plyngent.agent import Approval, Denial
|
||||
from plyngent.tools.context import record_approval
|
||||
|
||||
if has_private_grant(parsed.host, parsed.port, instance=inst):
|
||||
record_approval(Approval("grant", "process"))
|
||||
return
|
||||
|
||||
from plyngent.tools.workspace import get_policy_confirm_timeout
|
||||
@@ -105,17 +116,19 @@ async def ensure_host_allowed(
|
||||
)
|
||||
raise FetchPolicyError(msg)
|
||||
try:
|
||||
allowed = bool(hook(parsed.host, parsed.port, parsed.url, timeout))
|
||||
answer = hook(parsed.host, parsed.port, parsed.url, timeout)
|
||||
except Exception as exc:
|
||||
msg = f"fetch_local blocked private/loopback host {parsed.host}:{parsed.port} (confirm failed: {exc})"
|
||||
raise FetchPolicyError(msg) from exc
|
||||
if not allowed:
|
||||
if answer is not True:
|
||||
denial = Denial.from_answer(answer)
|
||||
msg = (
|
||||
f"fetch_local blocked private/loopback host {parsed.host}:{parsed.port} "
|
||||
f"(user declined or timed out after {timeout:g}s; not skipped by YOLO)"
|
||||
f"({assessment.reason}; {denial.clause()}; not skipped by YOLO)"
|
||||
)
|
||||
raise FetchPolicyError(msg)
|
||||
_ = grant_private_host(parsed.host, parsed.port, instance=inst)
|
||||
record_approval(Approval("user", "process"))
|
||||
|
||||
|
||||
def format_grant_preview(host: str, port: int, url: str) -> Sequence[str]:
|
||||
|
||||
@@ -36,7 +36,6 @@ DEFAULT_USER_AGENT = "plyngent-fetch/0.2"
|
||||
DEFAULT_MAX_REDIRECTS = 5
|
||||
DEFAULT_TIMEOUT_SECONDS = 30.0
|
||||
DEFAULT_MAX_BYTES = 1_000_000
|
||||
DEFAULT_MAX_CHARS = 32_000
|
||||
DEFAULT_MAX_BODY_CHARS_IN = 256_000 # request body size limit (tool arg)
|
||||
|
||||
# Cloud / link-local style targets never granted via policy UI.
|
||||
|
||||
@@ -18,24 +18,9 @@ from plyngent.tools.workspace import (
|
||||
)
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 30
|
||||
DEFAULT_MAX_OUTPUT_CHARS = 32_000
|
||||
DEFAULT_MAX_BATCH_STEPS = 20
|
||||
|
||||
|
||||
def truncate_output(text: str, label: str, max_chars: int = DEFAULT_MAX_OUTPUT_CHARS) -> str:
|
||||
"""Cap a per-stream output block; embed a truncate token when cut.
|
||||
|
||||
``label`` (stdout/stderr/batch) is kept for call-site clarity but the
|
||||
marker itself is the uniform ``[Truncated (N chars max.; M omitted).
|
||||
truncate_token=...]`` (no label), matching every other truncation site.
|
||||
Returns ``text`` unchanged when it fits.
|
||||
"""
|
||||
del label
|
||||
if max_chars < 1 or len(text) <= max_chars:
|
||||
return text
|
||||
from plyngent.tools.truncate_token import truncate_generic
|
||||
|
||||
return truncate_generic(text, max_chars)
|
||||
# A batch node's continuations nest, so the tree needs its own ceiling.
|
||||
MAX_BATCH_DEPTH = 4
|
||||
|
||||
|
||||
def format_command_result(
|
||||
@@ -53,9 +38,9 @@ def format_command_result(
|
||||
f"cwd={workdir_display}",
|
||||
f"cmd={shlex.join(command)}",
|
||||
"--- stdout ---",
|
||||
truncate_output(stdout, "stdout"),
|
||||
stdout,
|
||||
"--- stderr ---",
|
||||
truncate_output(stderr, "stderr"),
|
||||
stderr,
|
||||
]
|
||||
return "\n".join(parts)
|
||||
|
||||
@@ -82,7 +67,7 @@ class CommandStepResult:
|
||||
stdout: str
|
||||
stderr: str
|
||||
mix_stderr: bool
|
||||
captured: str # piped to the next step when provider sets pipe_out
|
||||
captured: str # stdout, fed to the next command of a pipe
|
||||
|
||||
|
||||
async def execute_argv(
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.tools.workspace import WorkspaceError
|
||||
from plyngent.tools.workspace import WorkspaceError, argv_shape_error
|
||||
|
||||
from .pty_session import active_pty_manager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
||||
|
||||
def open_pty_shape(args: Mapping[str, object]) -> str | None:
|
||||
"""Answer a session that cannot start, before anyone is asked about it."""
|
||||
shape_error = argv_shape_error(args.get("command"))
|
||||
if shape_error is not None:
|
||||
return f"error: `open_pty` {shape_error}"
|
||||
return None
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, validate=open_pty_shape)
|
||||
async def open_pty(command: list[str], *, cwd: str = ".") -> str:
|
||||
"""Open a PTY session running ``command`` (argv) under the workspace.
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.tools.shape import timeout_shape_error
|
||||
from plyngent.tools.workspace import WorkspaceError, argv_shape_error
|
||||
|
||||
from .command_exec import (
|
||||
@@ -9,8 +12,22 @@ from .command_exec import (
|
||||
format_command_result,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
||||
|
||||
def run_argv_shape(args: Mapping[str, object]) -> str | None:
|
||||
"""Answer a ``run_argv`` call whose arguments cannot run, before any prompt."""
|
||||
shape_error = argv_shape_error(args.get("argv"))
|
||||
if shape_error is not None:
|
||||
return f"error: `run_argv` {shape_error}"
|
||||
timeout_error = timeout_shape_error(args.get("timeout_seconds"))
|
||||
if timeout_error is not None:
|
||||
return f"error: `run_argv` {timeout_error}"
|
||||
return None
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, validate=run_argv_shape)
|
||||
async def run_argv(
|
||||
argv: list[str],
|
||||
*,
|
||||
@@ -20,9 +37,9 @@ async def run_argv(
|
||||
env: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Run ``argv`` (argv, no shell) under the workspace; capture stdout/stderr."""
|
||||
shape_error = argv_shape_error(argv)
|
||||
shape_error = run_argv_shape({"argv": argv, "timeout_seconds": timeout_seconds})
|
||||
if shape_error is not None:
|
||||
return f"error: `run_argv` {shape_error}"
|
||||
return shape_error
|
||||
try:
|
||||
result = await execute_argv(
|
||||
argv,
|
||||
|
||||
@@ -2,22 +2,37 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shlex
|
||||
from typing import Any, cast
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.tools.shape import timeout_shape_error
|
||||
from plyngent.tools.workspace import WorkspaceError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
from .command_exec import (
|
||||
DEFAULT_MAX_BATCH_STEPS,
|
||||
DEFAULT_MAX_OUTPUT_CHARS,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
MAX_BATCH_DEPTH,
|
||||
CommandStepResult,
|
||||
execute_argv,
|
||||
truncate_output,
|
||||
)
|
||||
|
||||
# Execution budget: stop the batch once accumulated output crosses this factor
|
||||
# of the base budget, so a runaway batch cannot keep spawning huge-output steps.
|
||||
BATCH_OUTPUT_BUDGET_CHARS = 32_000
|
||||
BATCH_OUTPUT_SOFT_CAP_FACTOR = 4
|
||||
|
||||
# A node is one command plus up to three continuations: ``then`` runs when it
|
||||
# succeeds, ``else`` when it fails, ``pipe`` takes its stdout as stdin. Only a
|
||||
# pipe hands data to its child, so a pipe stage may not branch: the stage's
|
||||
# result belongs to the node that owns the pipe.
|
||||
_CONTINUATIONS = ("then", "else", "pipe")
|
||||
_COMMAND_KEYS = frozenset({"argv", "cwd", "env", "stdin", "mix_stderr", "stop_on_error", "timeout_seconds"})
|
||||
_ACCEPTED_KEYS = "argv, cwd, env, stdin, mix_stderr, stop_on_error, timeout_seconds, then, else, pipe"
|
||||
|
||||
|
||||
def _as_bool(value: object, *, default: bool = False) -> bool:
|
||||
if value is None:
|
||||
@@ -48,36 +63,93 @@ def _as_str_dict(value: object) -> dict[str, str] | None:
|
||||
return {str(key): str(val) for key, val in cast("dict[object, object]", value).items()}
|
||||
|
||||
|
||||
def _parse_step(raw: object, index: int) -> dict[str, Any]:
|
||||
@dataclass
|
||||
class _Budget:
|
||||
"""Parsed node count: every node costs one, however deeply it nests."""
|
||||
|
||||
nodes: int = 0
|
||||
|
||||
def take(self, where: str) -> None:
|
||||
self.nodes += 1
|
||||
if self.nodes > DEFAULT_MAX_BATCH_STEPS:
|
||||
msg = f"more than {DEFAULT_MAX_BATCH_STEPS} nodes in one batch (at {where})"
|
||||
raise WorkspaceError(msg)
|
||||
|
||||
|
||||
def _parse_nodes(
|
||||
value: object,
|
||||
*,
|
||||
where: str,
|
||||
depth: int,
|
||||
budget: _Budget,
|
||||
fed: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""A continuation: one node or a list of them, in the order they run."""
|
||||
if value is None:
|
||||
return []
|
||||
items = cast("list[object]", value) if isinstance(value, list) else [value]
|
||||
parsed: list[dict[str, Any]] = []
|
||||
for index, item in enumerate(items):
|
||||
at = f"{where}[{index}]" if isinstance(value, list) else where
|
||||
parsed.append(_parse_node(item, where=at, depth=depth, budget=budget, fed=fed))
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_node(raw: object, *, where: str, depth: int, budget: _Budget, fed: bool) -> dict[str, Any]:
|
||||
if not isinstance(raw, dict):
|
||||
msg = f"steps[{index}] must be an object"
|
||||
msg = f"{where} must be an object"
|
||||
raise WorkspaceError(msg)
|
||||
if depth > MAX_BATCH_DEPTH:
|
||||
msg = f"{where}: nodes nest at most {MAX_BATCH_DEPTH} levels deep"
|
||||
raise WorkspaceError(msg)
|
||||
data = cast("dict[str, object]", raw)
|
||||
unknown = sorted(set(data) - _COMMAND_KEYS - set(_CONTINUATIONS))
|
||||
if unknown:
|
||||
hint = '; write a pipeline as {"pipe": [...]}, not "pipe_out"' if "pipe_out" in unknown else ""
|
||||
msg = f"{where} has unknown key(s) {', '.join(unknown)}; accepted keys are {_ACCEPTED_KEYS}{hint}"
|
||||
raise WorkspaceError(msg)
|
||||
budget.take(where)
|
||||
argv = data.get("argv")
|
||||
if not isinstance(argv, list) or not argv:
|
||||
msg = f"steps[{index}].argv must be a non-empty argv list"
|
||||
msg = f"{where}.argv must be a non-empty argv list"
|
||||
raise WorkspaceError(msg)
|
||||
argv_parts = cast("list[object]", argv)
|
||||
argv_str = [part for part in argv_parts if isinstance(part, str)]
|
||||
if len(argv_str) != len(argv_parts):
|
||||
msg = f"steps[{index}].argv must be a list of strings"
|
||||
msg = f"{where}.argv must be a list of strings"
|
||||
raise WorkspaceError(msg)
|
||||
stdin = data.get("stdin")
|
||||
if fed and stdin is not None:
|
||||
msg = f"{where}: stdin comes from the pipe; drop stdin or split the command"
|
||||
raise WorkspaceError(msg)
|
||||
timeout_error = timeout_shape_error(data.get("timeout_seconds"))
|
||||
if timeout_error is not None:
|
||||
msg = f"{where}.{timeout_error}"
|
||||
raise WorkspaceError(msg)
|
||||
cwd = data.get("cwd")
|
||||
stdin = data.get("stdin")
|
||||
stop = data.get("stop_on_error", None)
|
||||
return {
|
||||
node: dict[str, Any] = {
|
||||
"argv": argv_str,
|
||||
"cwd": str(cwd) if isinstance(cwd, str) and cwd else None,
|
||||
"env": _as_str_dict(data.get("env")),
|
||||
"stdin": None if stdin is None else str(stdin),
|
||||
"pipe_out": _as_bool(data.get("pipe_out"), default=False),
|
||||
"mix_stderr": _as_bool(data.get("mix_stderr"), default=False),
|
||||
"stop_on_error": None if stop is None else _as_bool(stop, default=True),
|
||||
"timeout_seconds": _as_float(data.get("timeout_seconds"), default=DEFAULT_TIMEOUT_SECONDS),
|
||||
}
|
||||
node["then"] = _parse_nodes(data.get("then"), where=f"{where}.then", depth=depth + 1, budget=budget, fed=False)
|
||||
node["else"] = _parse_nodes(data.get("else"), where=f"{where}.else", depth=depth + 1, budget=budget, fed=False)
|
||||
node["pipe"] = _parse_nodes(data.get("pipe"), where=f"{where}.pipe", depth=depth + 1, budget=budget, fed=True)
|
||||
if data.get("pipe") is not None and not node["pipe"]:
|
||||
msg = f"{where}.pipe must not be empty"
|
||||
raise WorkspaceError(msg)
|
||||
if fed and (node["then"] or node["else"]):
|
||||
msg = f"{where}: a pipe step takes no then/else; branch on the node that owns the pipe"
|
||||
raise WorkspaceError(msg)
|
||||
return node
|
||||
|
||||
|
||||
def _normalize_steps(steps: list[dict[str, object]] | str) -> list[object]:
|
||||
def _normalize_steps(steps: object) -> list[object]:
|
||||
if isinstance(steps, str):
|
||||
try:
|
||||
loaded: object = json.loads(steps)
|
||||
@@ -88,25 +160,74 @@ def _normalize_steps(steps: list[dict[str, object]] | str) -> list[object]:
|
||||
msg = "steps must be a JSON array of step objects"
|
||||
raise WorkspaceError(msg)
|
||||
return cast("list[object]", loaded)
|
||||
if not isinstance(steps, list):
|
||||
msg = "steps must be a JSON array of step objects"
|
||||
raise WorkspaceError(msg)
|
||||
return cast("list[object]", steps)
|
||||
|
||||
|
||||
def _format_step(index: int, result: CommandStepResult, *, pipe_out: bool) -> str:
|
||||
return "\n".join(
|
||||
def _read_batch(steps: object) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Parse a batch tree and count its nodes; raises when it cannot run."""
|
||||
raw_steps = _normalize_steps(steps)
|
||||
if not raw_steps:
|
||||
msg = "steps must not be empty"
|
||||
raise WorkspaceError(msg)
|
||||
budget = _Budget()
|
||||
return _parse_nodes(raw_steps, where="steps", depth=0, budget=budget, fed=False), budget.nodes
|
||||
|
||||
|
||||
def run_argv_batch_shape(args: Mapping[str, object]) -> str | None:
|
||||
"""Answer a batch that cannot run, before it reaches the human."""
|
||||
try:
|
||||
_ = _read_batch(args.get("steps"))
|
||||
except WorkspaceError as exc:
|
||||
return f"error: {exc}"
|
||||
except (TypeError, ValueError) as exc:
|
||||
return f"error: invalid batch arguments: {exc}"
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Step:
|
||||
"""One executed command, and whether its stdout fed another one."""
|
||||
|
||||
result: CommandStepResult
|
||||
depth: int
|
||||
via: tuple[str, int] | None
|
||||
feeds: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Stage:
|
||||
"""Exit code of a node's command stage — its pipe chain, for a piped node."""
|
||||
|
||||
code: int | None
|
||||
failed: bool
|
||||
captured: str
|
||||
|
||||
|
||||
def _format_step(index: int, step: _Step) -> str:
|
||||
result = step.result
|
||||
lines = [f"--- step {index} ---"]
|
||||
if step.depth > 0:
|
||||
lines.append(f"depth={step.depth}")
|
||||
if step.via is not None:
|
||||
lines.append(f"via={step.via[0]} of step {step.via[1]}")
|
||||
lines.extend(
|
||||
[
|
||||
f"--- step {index} ---",
|
||||
f"exit_code={result.exit_code if result.exit_code is not None else ''}",
|
||||
f"timed_out={'true' if result.timed_out else 'false'}",
|
||||
f"cwd={result.cwd_display}",
|
||||
f"cmd={shlex.join(result.command)}",
|
||||
f"pipe_out={'true' if pipe_out else 'false'}",
|
||||
f"pipe={'true' if step.feeds else 'false'}",
|
||||
f"mix_stderr={'true' if result.mix_stderr else 'false'}",
|
||||
"--- stdout ---",
|
||||
truncate_output(result.stdout, "stdout"),
|
||||
result.stdout,
|
||||
"--- stderr ---",
|
||||
truncate_output(result.stderr, "stderr"),
|
||||
result.stderr,
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _merge_env(base_env: dict[str, str] | None, step_env: dict[str, str] | None) -> dict[str, str] | None:
|
||||
@@ -115,46 +236,118 @@ def _merge_env(base_env: dict[str, str] | None, step_env: dict[str, str] | None)
|
||||
return {**(base_env or {}), **(step_env or {})}
|
||||
|
||||
|
||||
async def _run_batch_steps(
|
||||
steps: list[dict[str, Any]],
|
||||
*,
|
||||
cwd: str,
|
||||
env: dict[str, str] | None,
|
||||
stop_on_error: bool,
|
||||
) -> tuple[list[tuple[dict[str, Any], CommandStepResult]], bool]:
|
||||
results: list[tuple[dict[str, Any], CommandStepResult]] = []
|
||||
prev_capture: str | None = None
|
||||
prev_piped = False
|
||||
stopped_early = False
|
||||
total_chars = 0
|
||||
@dataclass
|
||||
class _Runner:
|
||||
"""Runs the node tree, remembering each command and when to stop."""
|
||||
|
||||
for index, step in enumerate(steps):
|
||||
stdin_text = prev_capture if prev_piped else step["stdin"]
|
||||
cwd: str
|
||||
env: dict[str, str] | None
|
||||
stop_on_error: bool
|
||||
steps: list[_Step] = field(default_factory=list)
|
||||
chars: int = 0
|
||||
stopped: bool = False
|
||||
|
||||
async def run_list(
|
||||
self,
|
||||
nodes: list[dict[str, Any]],
|
||||
*,
|
||||
depth: int,
|
||||
branch: str | None = None,
|
||||
parent: int | None = None,
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
via = (branch, parent) if branch is not None and parent is not None else None
|
||||
for node in nodes:
|
||||
if self.stopped:
|
||||
return
|
||||
_ = await self.run_node(node, depth=depth, via=via, cwd=cwd, env=env)
|
||||
|
||||
async def run_node(
|
||||
self,
|
||||
node: dict[str, Any],
|
||||
*,
|
||||
depth: int,
|
||||
via: tuple[str, int] | None,
|
||||
stdin: str | None = None,
|
||||
also_feeds: bool = False,
|
||||
stage_only: bool = False,
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> _Stage:
|
||||
# A continuation runs where its parent ran, unless it names its own
|
||||
# directory or environment; the batch's cwd/env are the outermost default.
|
||||
node_cwd = node["cwd"] if node["cwd"] is not None else (self.cwd if cwd is None else cwd)
|
||||
node_env = _merge_env(self.env if env is None else env, node["env"])
|
||||
result = await execute_argv(
|
||||
step["argv"],
|
||||
cwd=step["cwd"] if step["cwd"] is not None else cwd,
|
||||
env=_merge_env(env, step["env"]),
|
||||
stdin=stdin_text,
|
||||
timeout_seconds=step["timeout_seconds"],
|
||||
mix_stderr=step["mix_stderr"],
|
||||
node["argv"],
|
||||
cwd=node_cwd,
|
||||
env=node_env,
|
||||
stdin=stdin if stdin is not None else node["stdin"],
|
||||
timeout_seconds=node["timeout_seconds"],
|
||||
mix_stderr=node["mix_stderr"],
|
||||
)
|
||||
results.append((step, result))
|
||||
prev_capture = result.captured
|
||||
prev_piped = bool(step["pipe_out"])
|
||||
total_chars += len(result.stdout) + len(result.stderr)
|
||||
index = len(self.steps)
|
||||
self.steps.append(_Step(result, depth, via, feeds=bool(node["pipe"]) or also_feeds))
|
||||
self.chars += len(result.stdout) + len(result.stderr)
|
||||
stage = _Stage(result.exit_code, bool(result.timed_out) or result.exit_code != 0, result.captured)
|
||||
|
||||
failed = bool(result.timed_out) or result.exit_code != 0
|
||||
effective_stop = stop_on_error if step["stop_on_error"] is None else bool(step["stop_on_error"])
|
||||
if failed and effective_stop:
|
||||
return results, True
|
||||
if total_chars > DEFAULT_MAX_OUTPUT_CHARS * BATCH_OUTPUT_SOFT_CAP_FACTOR:
|
||||
return results, True
|
||||
del index # used for clarity in loop only
|
||||
chain: list[dict[str, Any]] = node["pipe"]
|
||||
piped = result.captured
|
||||
for position, child in enumerate(chain):
|
||||
if self.stopped:
|
||||
break
|
||||
stage = await self.run_node(
|
||||
child,
|
||||
depth=depth + 1,
|
||||
via=("pipe", index),
|
||||
stdin=piped,
|
||||
also_feeds=position + 1 < len(chain),
|
||||
stage_only=True,
|
||||
cwd=node_cwd,
|
||||
env=node_env,
|
||||
)
|
||||
piped = stage.captured
|
||||
|
||||
return results, stopped_early
|
||||
if self.chars > BATCH_OUTPUT_BUDGET_CHARS * BATCH_OUTPUT_SOFT_CAP_FACTOR:
|
||||
self.stopped = True
|
||||
return stage
|
||||
# A pipe stage's failure is the pipe owner's to answer: the chain still
|
||||
# ran to its last command, so only that result decides the branch.
|
||||
if stage_only:
|
||||
return stage
|
||||
if stage.failed:
|
||||
if node["else"]:
|
||||
await self.run_list(
|
||||
node["else"],
|
||||
depth=depth + 1,
|
||||
branch="else",
|
||||
parent=index,
|
||||
cwd=node_cwd,
|
||||
env=node_env,
|
||||
)
|
||||
elif self._effective_stop(node):
|
||||
self.stopped = True
|
||||
return stage
|
||||
if node["then"]:
|
||||
await self.run_list(
|
||||
node["then"],
|
||||
depth=depth + 1,
|
||||
branch="then",
|
||||
parent=index,
|
||||
cwd=node_cwd,
|
||||
env=node_env,
|
||||
)
|
||||
return stage
|
||||
|
||||
def _effective_stop(self, node: dict[str, Any]) -> bool:
|
||||
return self.stop_on_error if node["stop_on_error"] is None else bool(node["stop_on_error"])
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
||||
@tool(
|
||||
tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO,
|
||||
validate=run_argv_batch_shape,
|
||||
)
|
||||
async def run_argv_batch(
|
||||
steps: list[dict[str, object]] | str,
|
||||
*,
|
||||
@@ -162,53 +355,60 @@ async def run_argv_batch(
|
||||
env: dict[str, str] | None = None,
|
||||
stop_on_error: bool = True,
|
||||
) -> str:
|
||||
"""Run a serial batch of argv commands (no shell).
|
||||
"""Run a batch of argv commands (no shell), each with optional continuations.
|
||||
|
||||
Each element of ``steps`` is an object::
|
||||
A node is an object::
|
||||
|
||||
{
|
||||
"argv": ["git", "status"], # required argv
|
||||
"cwd": ".", # optional, else batch cwd
|
||||
"env": {"FOO": "1"}, # optional overlay
|
||||
"stdin": "...", # optional; unused if previous pipe_out
|
||||
"pipe_out": false, # if true, feed capture into *next* stdin
|
||||
"mix_stderr": false, # OS-level merge stderr into capture
|
||||
"stop_on_error": null, # override batch stop_on_error
|
||||
"argv": ["pytest", "-q"], # required argv
|
||||
"then": {"argv": [...]}, # runs when argv succeeds (node or list)
|
||||
"else": {"argv": [...]}, # runs when argv fails (node or list)
|
||||
"pipe": {"argv": [...]}, # takes argv's stdout as its stdin
|
||||
"cwd": ".", # optional, else batch cwd
|
||||
"env": {"FOO": "1"}, # optional overlay
|
||||
"stdin": "...", # optional; a pipe step takes it from the pipe
|
||||
"mix_stderr": false, # OS-level merge stderr into capture
|
||||
"stop_on_error": null, # override batch stop_on_error
|
||||
"timeout_seconds": 30
|
||||
}
|
||||
|
||||
``pipe_out`` is set on the **provider** step. Last-step ``pipe_out`` is an error.
|
||||
Default ``stop_on_error`` is true. Max 20 steps; total output budget 32k chars.
|
||||
``steps`` is a list of nodes, and ``then`` / ``else`` / ``pipe`` each take one
|
||||
node or a list of them, so the tree nests. A continuation runs where its
|
||||
parent ran: it inherits the parent's ``cwd`` and ``env`` (its own win, and
|
||||
the batch's are the outermost default). A ``pipe`` chain runs to its last
|
||||
command whatever the earlier ones did, and only that last exit code decides
|
||||
the branch; a pipe step takes no ``then`` / ``else`` — branch on the node
|
||||
that owns the pipe. A failure with no ``else`` stops the batch, unless that
|
||||
node sets ``stop_on_error: false``: it then skips its own continuations and
|
||||
the next sibling runs. A failure inside a continuation is nobody's to
|
||||
answer, so it stops the batch the same way.
|
||||
|
||||
Max 20 nodes and 4 nesting levels; the batch stops early once accumulated
|
||||
output passes the run budget (128k chars). Each command's output is kept
|
||||
whole for the model (a long result carries a ``document_id`` to read on), in
|
||||
a ``--- step N ---`` block that also says how it got there: ``depth=`` when
|
||||
nested, ``via=then of step N`` (or ``else`` / ``pipe``) and ``pipe=true``
|
||||
when its stdout fed another command.
|
||||
"""
|
||||
try:
|
||||
raw_steps = _normalize_steps(steps)
|
||||
if not raw_steps:
|
||||
return "error: steps must not be empty"
|
||||
if len(raw_steps) > DEFAULT_MAX_BATCH_STEPS:
|
||||
return f"error: at most {DEFAULT_MAX_BATCH_STEPS} steps per batch"
|
||||
|
||||
steps = [_parse_step(item, i) for i, item in enumerate(raw_steps)]
|
||||
if steps[-1]["pipe_out"]:
|
||||
return "error: last step has pipe_out=true but there is no next step"
|
||||
|
||||
results, stopped_early = await _run_batch_steps(steps, cwd=cwd, env=env, stop_on_error=stop_on_error)
|
||||
body = "\n".join(
|
||||
_format_step(i, result, pipe_out=bool(step["pipe_out"])) for i, (step, result) in enumerate(results)
|
||||
)
|
||||
if len(body) > DEFAULT_MAX_OUTPUT_CHARS:
|
||||
body = truncate_output(body, "batch")
|
||||
last_exit = results[-1][1].exit_code if results else ""
|
||||
return "\n".join(
|
||||
[
|
||||
f"steps={len(steps)} ran={len(results)} "
|
||||
f"stop_on_error={'true' if stop_on_error else 'false'} "
|
||||
f"stopped_early={'true' if stopped_early else 'false'}",
|
||||
body,
|
||||
"--- summary ---",
|
||||
f"last_exit={last_exit if last_exit is not None else ''}",
|
||||
]
|
||||
)
|
||||
nodes, count = _read_batch(steps)
|
||||
except WorkspaceError as exc:
|
||||
return f"error: {exc}"
|
||||
except (TypeError, ValueError) as exc:
|
||||
return f"error: invalid batch arguments: {exc}"
|
||||
|
||||
runner = _Runner(cwd=cwd, env=env, stop_on_error=stop_on_error)
|
||||
await runner.run_list(nodes, depth=0)
|
||||
|
||||
body = "\n".join(_format_step(i, step) for i, step in enumerate(runner.steps))
|
||||
last_exit = runner.steps[-1].result.exit_code if runner.steps else ""
|
||||
return "\n".join(
|
||||
[
|
||||
f"steps={count} ran={len(runner.steps)} "
|
||||
f"stop_on_error={'true' if stop_on_error else 'false'} "
|
||||
f"stopped_early={'true' if runner.stopped else 'false'}",
|
||||
body,
|
||||
"--- summary ---",
|
||||
f"last_exit={last_exit if last_exit is not None else ''}",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.tools.shape import shape_of
|
||||
from plyngent.tools.workspace import WorkspaceError
|
||||
|
||||
from .pty_terminal import KeySyntaxError, decode_write_data
|
||||
from .write_pty import write_pty_payload
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
||||
|
||||
def write_pty_keys_shape(args: Mapping[str, object]) -> str | None:
|
||||
"""Answer a bad marker list, before anyone is asked about the call."""
|
||||
data = args.get("data")
|
||||
if not isinstance(data, str):
|
||||
return f"error: `write_pty_keys` expects `data` as a string, got {shape_of(data)}"
|
||||
try:
|
||||
_ = decode_write_data(data)
|
||||
except KeySyntaxError as exc:
|
||||
return f"error: {exc}"
|
||||
return None
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, validate=write_pty_keys_shape)
|
||||
async def write_pty_keys(session_id: int, data: str) -> str:
|
||||
"""Write to a PTY after expanding ``<key>`` markers (never for normal typing).
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Pre-prompt argument checks shared by tools.
|
||||
|
||||
Tool arguments arrive as decoded JSON, so a parameter declared ``float`` or
|
||||
``list[str]`` can still hold anything at runtime. A value the tool can judge
|
||||
without running a thing is answered *before* the call reaches a confirm prompt
|
||||
(a tool declares the check with ``@tool(validate=…)``, see
|
||||
:class:`plyngent.agent.tools.ToolDefinition`): the human is never asked about a
|
||||
call that could only fail, and a value that would only break halfway through
|
||||
(e.g. a deadline that fails inside ``wait_for``, after the process was spawned)
|
||||
never gets that far.
|
||||
|
||||
Every check takes ``object``, so the isinstance calls stay meaningful and the
|
||||
tools themselves stay free of unnecessary-isinstance warnings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_SHAPES: dict[type, str] = {
|
||||
str: "a string",
|
||||
list: "an array",
|
||||
dict: "an object",
|
||||
bool: "a boolean",
|
||||
int: "a number",
|
||||
float: "a number",
|
||||
type(None): "null",
|
||||
}
|
||||
|
||||
|
||||
def shape_of(value: object) -> str:
|
||||
"""Model-facing name for a decoded JSON value (``a string``, ``an array``…)."""
|
||||
return _SHAPES.get(type(value), type(value).__name__)
|
||||
|
||||
|
||||
def got(value: object) -> str:
|
||||
"""What a rejected value is, quoting a string (``'soon'``) instead of naming it."""
|
||||
return repr(value) if isinstance(value, str) else shape_of(value)
|
||||
|
||||
|
||||
def number_value(value: object) -> float | None:
|
||||
"""The number *value* carries, or ``None`` when it is not one.
|
||||
|
||||
Numeric strings are accepted (models send them); a boolean is not a number
|
||||
here, and neither is non-numeric text.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int | float):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return float(value.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def timeout_shape_error(value: object, *, name: str = "timeout_seconds") -> str | None:
|
||||
"""Error when *name* is not a positive number of seconds; ``None`` when it is.
|
||||
|
||||
``None`` means the argument was omitted (the tool's default applies).
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
seconds = number_value(value)
|
||||
if seconds is None:
|
||||
return f"{name} must be a number, not {got(value)}"
|
||||
if seconds <= 0:
|
||||
return f"{name} must be greater than 0 (got {value!r})"
|
||||
return None
|
||||
@@ -18,7 +18,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.agent import Approval, Denial, ToolTag, tool
|
||||
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
|
||||
from plyngent.skills import (
|
||||
SKILL_FILE,
|
||||
@@ -30,6 +30,7 @@ from plyngent.skills import (
|
||||
resolve_skill_file,
|
||||
validate_skill_name,
|
||||
)
|
||||
from plyngent.tools.context import record_approval
|
||||
from plyngent.tools.file.read import read_file as _read_file
|
||||
from plyngent.tools.file.regex_files import DEFAULT_MAX_MATCHES
|
||||
from plyngent.tools.file.regex_files import regex_files as _regex_files
|
||||
@@ -260,9 +261,11 @@ class SkillWriteDecision:
|
||||
# True keeps the grant for the rest of this chat session (the standing
|
||||
# alternative is ``[skills].allow_write`` in the config).
|
||||
session: bool = False
|
||||
# Why a denial: declined, timed out, cancelled, or nobody could be asked.
|
||||
denial: Denial | None = None
|
||||
|
||||
|
||||
type SkillWriteConfirmHook = Callable[[Path, str, str, float], SkillWriteDecision | bool | None]
|
||||
type SkillWriteConfirmHook = Callable[[Path, str, str, float], SkillWriteDecision | Denial | bool | None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -328,9 +331,12 @@ def _prompt_write(
|
||||
return f"error: skill write confirm failed: {exc}"
|
||||
allowed = decision is True or (isinstance(decision, SkillWriteDecision) and decision.allow)
|
||||
if not allowed:
|
||||
return f"error: skill write to {skill_dir} denied (declined or timed out after {policy.timeout:g}s)"
|
||||
if isinstance(decision, SkillWriteDecision) and decision.session and session is not None:
|
||||
denial = decision.denial if isinstance(decision, SkillWriteDecision) else None
|
||||
return f"error: skill write to {skill_dir} denied ({(denial or Denial.from_answer(decision)).clause()})"
|
||||
keys_session = isinstance(decision, SkillWriteDecision) and decision.session
|
||||
if keys_session and session is not None:
|
||||
session.skill_write_roots.add(skill_dir)
|
||||
record_approval(Approval("user", "session" if keys_session else "once"))
|
||||
return None
|
||||
|
||||
|
||||
@@ -342,6 +348,9 @@ def _authorize_write(skill_dir: Path, target: Path, *, action: str, detail: str)
|
||||
checked first and always wins; then a session grant, then
|
||||
``[skills].allow_write``, then the host's confirm hook. Without a hook the
|
||||
write is denied rather than silently allowed.
|
||||
|
||||
Each allow that skipped the prompt is recorded too, so the result says why
|
||||
nobody was asked: ``grant (session)`` or ``config (policy)``.
|
||||
"""
|
||||
denied = _denied_by_policy(target, skill_dir)
|
||||
if denied is not None:
|
||||
@@ -350,6 +359,7 @@ def _authorize_write(skill_dir: Path, target: Path, *, action: str, detail: str)
|
||||
|
||||
session = get_session()
|
||||
if session is not None and skill_dir in session.skill_write_roots:
|
||||
record_approval(Approval("grant", "session"))
|
||||
return None
|
||||
policy = get_skill_write_policy()
|
||||
if policy is None or (policy.confirm is None and not policy.allow_all):
|
||||
@@ -358,6 +368,7 @@ def _authorize_write(skill_dir: Path, target: Path, *, action: str, detail: str)
|
||||
"set [skills].allow_write = true to allow skill writes"
|
||||
)
|
||||
if policy.allow_all:
|
||||
record_approval(Approval("config", "policy"))
|
||||
return None
|
||||
return _prompt_write(skill_dir, action=action, detail=detail, policy=policy, session=session)
|
||||
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
"""Truncate-token cursors for resuming truncated tool results.
|
||||
"""Truncate-token cursors for resuming truncated reads.
|
||||
|
||||
Every truncation site (the agent loop's generic tool-result cap,
|
||||
``run_argv`` / ``run_argv_batch`` per-stream caps, ``read_file``,
|
||||
``fetch`` / ``fetch_local``, and request-time compact shrinks) appends the same
|
||||
compact marker when output is cut short::
|
||||
Two sources use a truncate token:
|
||||
|
||||
[Truncated (32000 chars max.; 500 omitted). truncate_token=...]
|
||||
- ``read_file`` caps a slice with a ``file`` token; the remainder is re-read from
|
||||
disk by ``location`` + ``offset``, so it survives for as long as the file does
|
||||
(and ``numbered`` resumes the ``N|`` view for ``edit_lineno``).
|
||||
- A user ``@path`` mention inlines a file with a ``memory`` token. The mention
|
||||
carries text into a user turn rather than a tool message, so the remainder
|
||||
lives in a short-lived in-memory store that is cleared when the agent process
|
||||
exits.
|
||||
|
||||
The model passes the token to ``get_truncated`` to read the next chunk without
|
||||
re-requesting the whole source or raising limits. Tokens chain: each truncated
|
||||
chunk carries a fresh token, so ``get_truncated`` keeps resuming until the
|
||||
source is exhausted (then no marker is emitted).
|
||||
Tool results no longer use this module. A result is stored whole in its own
|
||||
message, and the request-prep pass trims the request copy with a marker naming a
|
||||
document (``read_document`` / ``search_document``; see ``tools/documents.py``).
|
||||
|
||||
A ``file`` remainder is re-read from disk by ``location`` + ``offset``, so it
|
||||
survives for as long as the file does. Everything else — arbitrary tool output
|
||||
(run_argv stdout, todo renders, …) and ``fetch`` / ``fetch_local`` bodies — have
|
||||
no source worth re-running (a re-fetch costs a round trip and re-hits the remote),
|
||||
so its remainder lives in a short-lived in-memory store (``kind="memory"``) that is
|
||||
forgotten when the agent process exits; the model must re-run the tool after
|
||||
that.
|
||||
Either way the model passes the token to ``get_truncated`` to read the next
|
||||
chunk without re-requesting the whole source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -141,7 +138,9 @@ def truncate_to_memory(text: str, max_chars: int) -> tuple[str, TruncateToken |
|
||||
|
||||
Nothing is stored when the text fits, so a caller that only needs the
|
||||
bounded text does not pay for a remainder. Returns ``(bounded_text, token)``
|
||||
where ``token`` is ``None`` when the source is exhausted.
|
||||
where ``token`` is ``None`` when the source is exhausted. A user ``@path``
|
||||
mention is the caller: its inlined text has no re-readable source, so the
|
||||
remainder waits in memory until the process exits.
|
||||
"""
|
||||
if max_chars < 1 or len(text) <= max_chars:
|
||||
return text, None
|
||||
@@ -155,14 +154,3 @@ def truncate_to_memory(text: str, max_chars: int) -> tuple[str, TruncateToken |
|
||||
limit=max_chars,
|
||||
total_len=len(text),
|
||||
)
|
||||
|
||||
|
||||
def truncate_generic(text: str, max_chars: int) -> str:
|
||||
"""Cap arbitrary tool output; embed a memory truncate token (chainable).
|
||||
|
||||
The full remainder is kept in the in-memory store so ``get_truncated`` can
|
||||
resume it. Returns ``text`` unchanged (no marker) when it fits within
|
||||
``max_chars``.
|
||||
"""
|
||||
bounded, _ = truncate_to_memory(text, max_chars)
|
||||
return bounded
|
||||
|
||||
@@ -8,7 +8,6 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 30.0
|
||||
DEFAULT_MAX_OUTPUT_CHARS = 64_000
|
||||
|
||||
|
||||
def is_git_repo(root: Path) -> bool:
|
||||
@@ -32,13 +31,6 @@ class _GitResult:
|
||||
self.returncode = returncode
|
||||
|
||||
|
||||
def _truncate(text: str) -> str:
|
||||
if len(text) <= DEFAULT_MAX_OUTPUT_CHARS:
|
||||
return text
|
||||
omitted = len(text) - DEFAULT_MAX_OUTPUT_CHARS
|
||||
return f"{text[:DEFAULT_MAX_OUTPUT_CHARS]}\n...[truncated {omitted} characters]"
|
||||
|
||||
|
||||
def _run_git(root: Path, args: list[str], *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> _GitResult:
|
||||
argv = ["git", "-c", "color.ui=false", *args]
|
||||
try:
|
||||
@@ -57,12 +49,12 @@ def _run_git(root: Path, args: list[str], *, timeout: float = DEFAULT_TIMEOUT_SE
|
||||
except OSError as exc:
|
||||
return _GitResult(ok=False, stdout="", stderr=str(exc), returncode=1)
|
||||
|
||||
stdout = _truncate(completed.stdout or "")
|
||||
stderr = _truncate(completed.stderr or "")
|
||||
# Output is kept whole; the request-prep pass trims it on the wire and names
|
||||
# its document, so a long log/diff reads on rather than re-running git.
|
||||
return _GitResult(
|
||||
ok=completed.returncode == 0,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
stdout=completed.stdout or "",
|
||||
stderr=completed.stderr or "",
|
||||
returncode=completed.returncode,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from plyngent.tools.command_scan import basename, unwrap_command
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
|
||||
from plyngent.agent.tools import Denial
|
||||
from plyngent.tools.context import InstanceState, SessionState
|
||||
|
||||
DEFAULT_COMMAND_DENYLIST: frozenset[str] = frozenset(
|
||||
@@ -89,7 +90,7 @@ def mode_covers(granted: AccessMode, required: AccessMode) -> bool:
|
||||
|
||||
|
||||
# Hook: (basename, argv, timeout_seconds) -> True allow for this session basename.
|
||||
type PolicyConfirmHook = Callable[[str, Sequence[str], float], bool]
|
||||
type PolicyConfirmHook = Callable[[str, Sequence[str], float], bool | Denial]
|
||||
|
||||
|
||||
class WorkspaceError(ValueError):
|
||||
@@ -114,7 +115,8 @@ class WorkspacePolicy:
|
||||
policy_confirm_timeout_seconds: float = DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS
|
||||
# Static TOML pre-allow (resolved path → mode); never prompts.
|
||||
config_allow: dict[Path, AccessMode] = field(default_factory=dict)
|
||||
# Process-scoped YOLO / --yes grants (resolved path → mode); never persisted.
|
||||
# Process-scoped grants (resolved path → mode): a host answer kept only for
|
||||
# this process. YOLO mode never fills it; nothing is auto-approved here.
|
||||
yolo_allow: dict[Path, AccessMode] = field(default_factory=dict)
|
||||
# Host-contributed read-only roots (skill directories): every path tool may
|
||||
# read them, nothing may write them. Not a grant, so they stay out of
|
||||
@@ -445,9 +447,17 @@ def resolve_path(path: str | Path, *, required: AccessMode = AccessMode.WRITE) -
|
||||
candidate = root / candidate
|
||||
resolved = candidate.expanduser().resolve()
|
||||
if not _under_any_root(resolved, instance, policy) and not _path_grant_covers(resolved, required):
|
||||
# A refused *write* is often a scratch file the model meant to keep in
|
||||
# the workspace: name that way out too, next to asking the human.
|
||||
scratch = (
|
||||
"; if the file is only scratch, call new_temporary_workspace and write under the absolute path it returns"
|
||||
if required is AccessMode.WRITE
|
||||
else ""
|
||||
)
|
||||
msg = (
|
||||
f"path escapes workspace root ({root}): {path}; "
|
||||
f"call request_directory_access with mode={required.name.lower()!r} to request access outside the workspace"
|
||||
f"{scratch}"
|
||||
)
|
||||
raise WorkspaceError(msg)
|
||||
# Normalize separators so denylist entries like ``/secrets/`` match on Windows.
|
||||
@@ -557,7 +567,14 @@ def check_command_allowed(argv: list[str]) -> None:
|
||||
Denylisted programs are not hard-rejected when a policy confirm hook is
|
||||
installed: the human is asked (with a timeout; default deny). Session grants
|
||||
skip re-prompting for the same program. Independent of YOLO soft-confirm.
|
||||
|
||||
Either way the call records how it got past the denylist, so the tool result
|
||||
says it: ``user (process)`` for this call's answer, ``grant (process)`` when
|
||||
an earlier one already covers this program.
|
||||
"""
|
||||
from plyngent.agent import Approval, Denial
|
||||
from plyngent.tools.context import record_approval
|
||||
|
||||
shape_error = argv_shape_error(argv)
|
||||
if shape_error is not None:
|
||||
msg = f"command {shape_error}"
|
||||
@@ -569,22 +586,22 @@ def check_command_allowed(argv: list[str]) -> None:
|
||||
for hit in match_command_denylist(argv, policy.command_denylist):
|
||||
binary = hit.program
|
||||
if binary in policy.policy_allowed_commands:
|
||||
record_approval(Approval("grant", "process"))
|
||||
continue
|
||||
why = f": {hit.keyword!r}" if hit.keyword else ""
|
||||
hook = policy.policy_confirm_hook
|
||||
if hook is None:
|
||||
msg = f"command denied by policy (basename {binary!r} is blocked{why})"
|
||||
msg = f"command denied by policy (basename {binary!r} is blocked{why}; no policy confirm hook)"
|
||||
raise WorkspaceError(msg)
|
||||
timeout = policy.policy_confirm_timeout_seconds
|
||||
try:
|
||||
allowed = bool(hook(binary, list(argv), timeout))
|
||||
answer = hook(binary, list(argv), timeout)
|
||||
except Exception as exc:
|
||||
msg = f"command denied by policy (basename {binary!r}; confirm failed: {exc})"
|
||||
raise WorkspaceError(msg) from exc
|
||||
if not allowed:
|
||||
msg = (
|
||||
f"command denied by policy (basename {binary!r} is blocked{why}; "
|
||||
f"user declined or timed out after {timeout:g}s)"
|
||||
)
|
||||
if answer is not True:
|
||||
denial = Denial.from_answer(answer)
|
||||
msg = f"command denied by policy (basename {binary!r} is blocked{why}; {denial.clause()})"
|
||||
raise WorkspaceError(msg)
|
||||
policy.policy_allowed_commands.add(binary)
|
||||
record_approval(Approval("user", "process"))
|
||||
|
||||
@@ -1,27 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from plyngent.agent.budget import estimate_message_chars, truncate_tool_result
|
||||
from plyngent.agent.budget import (
|
||||
cap_hard_tool_text,
|
||||
cap_tool_message,
|
||||
estimate_message_chars,
|
||||
truncate_for_request,
|
||||
)
|
||||
from plyngent.lmproto.openai_compatible.model import ToolChatMessage, UserChatMessage
|
||||
|
||||
|
||||
def test_truncate_tool_result_short() -> None:
|
||||
assert truncate_tool_result("hello", 100) == "hello"
|
||||
def test_cap_hard_tool_text_short_untouched() -> None:
|
||||
assert cap_hard_tool_text("hello", 100) == "hello"
|
||||
|
||||
|
||||
def test_truncate_tool_result_long() -> None:
|
||||
text = "a" * 50
|
||||
out = truncate_tool_result(text, 20)
|
||||
assert "Truncated" in out
|
||||
assert "20" in out
|
||||
assert "30" in out
|
||||
# Generic tool results now embed a memory token too (resumable via get_truncated).
|
||||
assert "truncate_token=" in out
|
||||
assert out.startswith(("a" * 10, "a" * 20)) # marker is budgeted
|
||||
def test_cap_hard_tool_text_cuts_with_the_notice_up_front() -> None:
|
||||
"""The notice leads: the wire cap sends the head, so a trailing one is lost."""
|
||||
out = cap_hard_tool_text("a" * 50, 20)
|
||||
head, _, body = out.partition("\n")
|
||||
assert "20-char ceiling" in head
|
||||
assert "30 chars beyond it were dropped and cannot be read back" in head
|
||||
assert body == "a" * 20
|
||||
|
||||
|
||||
def test_truncate_tool_result_fits_no_marker() -> None:
|
||||
assert truncate_tool_result("tiny", 100) == "tiny"
|
||||
assert "Truncated" not in truncate_tool_result("tiny", 100)
|
||||
def test_cap_tool_message_names_the_document() -> None:
|
||||
message = ToolChatMessage(content="a" * 50, tool_call_id="call-7")
|
||||
capped = cap_tool_message(message, 20)
|
||||
assert capped is not message
|
||||
assert capped.tool_call_id == "call-7"
|
||||
assert "document_id=call-7" in capped.content
|
||||
assert capped.content.startswith("a")
|
||||
assert "storage ceiling" not in capped.content
|
||||
# The stored message keeps its body whole.
|
||||
assert message.content == "a" * 50
|
||||
|
||||
|
||||
def test_cap_tool_message_flags_a_body_the_ceiling_cut() -> None:
|
||||
"""A cut body is not the whole output, so the marker must not imply it is."""
|
||||
stored = cap_hard_tool_text("a" * 500, 100)
|
||||
capped = cap_tool_message(ToolChatMessage(content=stored, tool_call_id="call-9"), 60)
|
||||
assert "document_id=call-9" in capped.content
|
||||
assert "cut at the storage ceiling" in capped.content
|
||||
|
||||
|
||||
def test_cap_tool_message_short_untouched() -> None:
|
||||
message = ToolChatMessage(content="tiny", tool_call_id="call-1")
|
||||
assert cap_tool_message(message, 100) is message
|
||||
|
||||
|
||||
def test_truncate_for_request_caps_only_tool_messages() -> None:
|
||||
user = UserChatMessage(content="u" * 1000)
|
||||
tool = ToolChatMessage(content="t" * 1000, tool_call_id="call-1")
|
||||
out = truncate_for_request([user, tool], max_tool_result_chars=200)
|
||||
assert out[0] is user
|
||||
capped = out[1]
|
||||
assert isinstance(capped, ToolChatMessage)
|
||||
assert "document_id=call-1" in capped.content
|
||||
assert len(capped.content) <= 200
|
||||
|
||||
|
||||
def test_estimate_message_chars() -> None:
|
||||
|
||||
@@ -8,7 +8,6 @@ from plyngent.agent.budget import (
|
||||
compact_messages_for_request,
|
||||
estimate_messages_tokens,
|
||||
measure_messages_tokens,
|
||||
truncate_tool_result,
|
||||
)
|
||||
from plyngent.agent.loop import run_chat_loop
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
@@ -33,27 +32,6 @@ if TYPE_CHECKING:
|
||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||
|
||||
|
||||
def test_truncate_tool_result_short() -> None:
|
||||
assert truncate_tool_result("hello", 100) == "hello"
|
||||
|
||||
|
||||
def test_truncate_tool_result_long() -> None:
|
||||
text = "a" * 50
|
||||
out = truncate_tool_result(text, 20)
|
||||
assert "Truncated" in out
|
||||
assert "20" in out
|
||||
assert "30" in out
|
||||
# Generic tool results now embed a memory token too (resumable via get_truncated).
|
||||
assert "truncate_token=" in out
|
||||
|
||||
|
||||
def test_truncate_tool_result_no_hint() -> None:
|
||||
text = "a" * 50
|
||||
out = truncate_tool_result(text, 20)
|
||||
assert "Truncated" in out
|
||||
assert "truncate_token=" in out
|
||||
|
||||
|
||||
def test_compact_shrinks_old_tool_results() -> None:
|
||||
messages: list[AnyChatMessage] = [
|
||||
UserChatMessage(content="start"),
|
||||
@@ -92,8 +70,8 @@ def test_compact_shrinks_old_tool_results() -> None:
|
||||
assert isinstance(compacted[2], ToolChatMessage)
|
||||
assert len(compacted[2].content) < original_len
|
||||
assert "Truncated" in compacted[2].content
|
||||
# Compact shrinks carry a token too (resumable via get_truncated).
|
||||
assert "truncate_token=" in compacted[2].content
|
||||
# A shrunk result names its document (read_document / search_document).
|
||||
assert "document_id=1" in compacted[2].content
|
||||
# Full history unchanged
|
||||
assert isinstance(messages[2], ToolChatMessage)
|
||||
assert len(messages[2].content) == original_len
|
||||
|
||||
@@ -33,7 +33,7 @@ async def test_confirm_allow() -> None:
|
||||
danger=classify_danger,
|
||||
on_confirm=on_confirm,
|
||||
)
|
||||
assert await registry.execute("delete_path", '{"path": "a.txt"}') == "deleted a.txt"
|
||||
assert await registry.execute("delete_path", '{"path": "a.txt"}') == "[approval] user (once)\ndeleted a.txt"
|
||||
assert len(decisions) == 1
|
||||
assert await registry.execute("read_file", '{"path": "a.txt"}') == "read a.txt"
|
||||
assert len(decisions) == 1
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal, overload
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING, Literal, cast, overload
|
||||
|
||||
import pytest
|
||||
from msgspec import UNSET
|
||||
@@ -928,6 +929,128 @@ async def test_retry_keeps_committed_tools_after_second_round_fails() -> None:
|
||||
await store.close()
|
||||
|
||||
|
||||
async def test_retry_keeps_lineno_reads_across_attempt_tasks(tmp_path: object) -> None:
|
||||
"""A numbered read before a failed round still enables edits after retry.
|
||||
|
||||
Each turn attempt runs in its own task (the host wraps turns in
|
||||
``asyncio.create_task``), so the retry attempt's context does not carry the
|
||||
first attempt's ContextVar binding. Without ``retry`` re-binding the lineno
|
||||
tracker, ``read_file(with_lineno=true)`` silently records nothing and
|
||||
``edit_lineno`` rejects every line edit — even after re-reading the file as
|
||||
its own error message instructs.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.tools.context import InstanceState
|
||||
from plyngent.tools.file import edit_lineno, read_file
|
||||
from plyngent.tools.workspace import (
|
||||
clear_workspace_allowlist,
|
||||
clear_workspace_root,
|
||||
set_workspace_root,
|
||||
)
|
||||
|
||||
root = cast("Path", tmp_path).resolve()
|
||||
(root / "f.txt").write_text("alpha\nbravo\ncharlie\n", encoding="utf-8")
|
||||
instance = InstanceState(workspace_root=root)
|
||||
from plyngent.tools.context import bind_instance
|
||||
|
||||
with bind_instance(instance):
|
||||
clear_workspace_allowlist()
|
||||
_ = set_workspace_root(root)
|
||||
try:
|
||||
registry = ToolRegistry([read_file, edit_lineno], instance_state=instance)
|
||||
|
||||
class ReadThenBoom:
|
||||
"""Round 1 reads with line numbers; round 2's model call fails."""
|
||||
|
||||
n: int
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.n = 0
|
||||
|
||||
@overload
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
|
||||
) -> ChatCompletionResponse: ...
|
||||
|
||||
@overload
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: Literal[True]
|
||||
) -> AsyncIterator[ChatCompletionChunk]: ...
|
||||
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||
del stream
|
||||
self.n += 1
|
||||
if self.n == 1:
|
||||
return _response(
|
||||
AssistantChatMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(
|
||||
id="c1",
|
||||
function=AssistantFunctionTool(
|
||||
name="read_file",
|
||||
arguments='{"path": "f.txt", "with_lineno": true}',
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
if self.n == 2:
|
||||
msg = "api down after read"
|
||||
raise RuntimeError(msg)
|
||||
if self.n == 3:
|
||||
edit_args = '{"path": "f.txt", "start_line": 2, "end_line": 2, "new_content": "BRAVO\\n"}'
|
||||
return _response(
|
||||
AssistantChatMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(
|
||||
id="c2",
|
||||
function=AssistantFunctionTool(
|
||||
name="edit_lineno",
|
||||
arguments=edit_args,
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
return _response(AssistantChatMessage(content="done"))
|
||||
|
||||
client = ReadThenBoom()
|
||||
agent = ChatAgent(
|
||||
client,
|
||||
model="m",
|
||||
tools=registry,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
async def run_attempt() -> list[object]:
|
||||
return [e async for e in agent.run("edit line 2")]
|
||||
|
||||
# First attempt in its own task (mirrors run_cancellable): commits
|
||||
# the read, then the next model round fails.
|
||||
with pytest.raises(RuntimeError, match="api down after read"):
|
||||
await asyncio.create_task(run_attempt())
|
||||
assert isinstance(agent.messages[-1], ToolChatMessage)
|
||||
|
||||
# Retry in a *fresh* task: its context copy has no tracker bound.
|
||||
async def run_retry() -> list[object]:
|
||||
return [e async for e in agent.retry()]
|
||||
|
||||
events = await asyncio.create_task(run_retry())
|
||||
results = [e for e in events if isinstance(e, ToolResultEvent)]
|
||||
assert results, "retry must run the edit_lineno call"
|
||||
assert "replaced lines 2-2" in results[0].message.content
|
||||
assert (root / "f.txt").read_text(encoding="utf-8") == "alpha\nBRAVO\ncharlie\n"
|
||||
assert agent.pending_retry_text is None
|
||||
finally:
|
||||
clear_workspace_allowlist()
|
||||
clear_workspace_root()
|
||||
|
||||
|
||||
async def test_chat_agent_system_prompt_prepended() -> None:
|
||||
from plyngent.lmproto.openai_compatible.model import SystemChatMessage
|
||||
|
||||
@@ -942,7 +1065,7 @@ async def test_chat_agent_system_prompt_prepended() -> None:
|
||||
async def test_tool_result_char_budget() -> None:
|
||||
@tool(register=False)
|
||||
def big() -> str:
|
||||
return "x" * 100
|
||||
return "x" * 2000
|
||||
|
||||
registry = ToolRegistry([big])
|
||||
client = ScriptedClient(
|
||||
@@ -970,16 +1093,21 @@ async def test_tool_result_char_budget() -> None:
|
||||
model="m",
|
||||
tools=registry,
|
||||
stream=False,
|
||||
max_tool_result_chars=20,
|
||||
max_tool_result_chars=200,
|
||||
parallel_tools=False,
|
||||
)
|
||||
]
|
||||
from plyngent.lmproto.openai_compatible.model import ToolChatMessage
|
||||
|
||||
tool_msgs = [m for m in messages if isinstance(m, ToolChatMessage)]
|
||||
assert len(tool_msgs) == 1
|
||||
assert tool_msgs[0].content.startswith("x" * 20)
|
||||
assert "Truncated" in tool_msgs[0].content
|
||||
# The stored result stays whole; only the wire copy is capped, and it names
|
||||
# the document that reads the rest.
|
||||
stored = [m for m in messages if isinstance(m, ToolChatMessage)]
|
||||
assert len(stored) == 1
|
||||
assert stored[0].content == "x" * 2000
|
||||
wire = next(m for m in client.calls[-1].messages if isinstance(m, ToolChatMessage))
|
||||
assert wire.content.startswith("x")
|
||||
assert "document_id=1" in wire.content
|
||||
assert len(wire.content) < 2000
|
||||
|
||||
|
||||
async def test_empty_completion_raises() -> None:
|
||||
|
||||
@@ -253,5 +253,5 @@ async def test_a_write_holds_its_file_across_the_confirm() -> None:
|
||||
await asyncio.sleep(0) # the second write waits for the file, not for the human
|
||||
assert order == ["confirm write_a"]
|
||||
answered.set()
|
||||
assert await asyncio.gather(first, second) == ["a", "b"]
|
||||
assert await asyncio.gather(first, second) == ["[approval] user (once)\na", "b"]
|
||||
assert order == ["confirm write_a", "a", "b"]
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""The ``[approval]`` line: a confirmed call says how it got through."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import (
|
||||
Approval,
|
||||
BatchConfirmDecision,
|
||||
ToolRegistry,
|
||||
ToolTag,
|
||||
format_approval,
|
||||
most_specific_approval,
|
||||
tool,
|
||||
)
|
||||
from plyngent.agent.budget import cap_tool_message
|
||||
from plyngent.lmproto.openai_compatible.model import ToolChatMessage
|
||||
from plyngent.tools.context import SessionState, bind_tool_context, record_approval
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from plyngent.agent import ToolConfirmRequest
|
||||
|
||||
|
||||
def _danger(name: str, _args: Mapping[str, object]) -> str | None:
|
||||
return f"mutate {name}"
|
||||
|
||||
|
||||
def _allow(_name: str, _args: Mapping[str, object], _reason: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@tool(register=False)
|
||||
async def quiet() -> str:
|
||||
return "quiet"
|
||||
|
||||
|
||||
@tool(register=False)
|
||||
async def mutate() -> str:
|
||||
return "ran"
|
||||
|
||||
|
||||
async def test_a_call_that_needs_no_confirmation_carries_no_line() -> None:
|
||||
"""The common case stays byte-for-byte what the tool returned."""
|
||||
|
||||
def danger(_name: str, _args: Mapping[str, object]) -> str | None:
|
||||
return None
|
||||
|
||||
def on_confirm(_name: str, _args: Mapping[str, object], _reason: str) -> bool:
|
||||
msg = "no prompt expected"
|
||||
raise AssertionError(msg)
|
||||
|
||||
reg = ToolRegistry([quiet], danger=danger, on_confirm=on_confirm)
|
||||
assert await reg.execute("quiet", "{}") == "quiet"
|
||||
|
||||
|
||||
async def test_a_single_prompt_leads_the_result() -> None:
|
||||
reg = ToolRegistry([mutate], danger=_danger, on_confirm=_allow)
|
||||
assert await reg.execute("mutate", "{}") == "[approval] user (once)\nran"
|
||||
|
||||
|
||||
async def test_yolo_mode_says_it_dropped_the_prompt() -> None:
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
|
||||
async def yolo_ok() -> str:
|
||||
return "y"
|
||||
|
||||
reg = ToolRegistry([yolo_ok], danger=_danger, on_confirm=_allow, yolo=True)
|
||||
assert await reg.execute("yolo_ok", "{}") == "[approval] yolo (auto)\ny"
|
||||
|
||||
|
||||
async def test_a_batch_answer_covers_its_round() -> None:
|
||||
def on_batch(_requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
|
||||
return BatchConfirmDecision(allowed=True)
|
||||
|
||||
reg = ToolRegistry([mutate], danger=_danger, on_confirm_batch=on_batch)
|
||||
calls = [("mutate", "{}"), ("mutate", "{}")]
|
||||
decisions = await reg.prepare_batch_confirm(calls)
|
||||
results = [
|
||||
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
|
||||
]
|
||||
assert results == ["[approval] user (round)\nran", "[approval] user (round)\nran"]
|
||||
|
||||
|
||||
async def test_a_session_grant_says_nobody_was_asked_this_time() -> None:
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.TRUSTABLE, register=False)
|
||||
async def trust_me() -> str:
|
||||
return "ok"
|
||||
|
||||
prompts: list[str] = []
|
||||
|
||||
def on_confirm(name: str, _args: Mapping[str, object], _reason: str) -> bool:
|
||||
prompts.append(name)
|
||||
return True
|
||||
|
||||
session = SessionState(session_id=1)
|
||||
reg = ToolRegistry([trust_me], danger=_danger, on_confirm=on_confirm, session_state=session)
|
||||
with bind_tool_context(session=session):
|
||||
assert await reg.execute("trust_me", "{}") == "[approval] user (once)\nok"
|
||||
assert await reg.execute("trust_me", "{}") == "[approval] grant (session)\nok"
|
||||
assert prompts == ["trust_me"]
|
||||
|
||||
|
||||
async def test_a_denied_call_carries_no_line() -> None:
|
||||
reg = ToolRegistry([mutate], danger=_danger, on_confirm=lambda *_args: False)
|
||||
out = await reg.execute("mutate", "{}")
|
||||
assert out.startswith("error: ")
|
||||
assert "[approval]" not in out
|
||||
|
||||
|
||||
async def test_a_failed_call_carries_no_line() -> None:
|
||||
"""An approved call that failed is an error first; the answer explains nothing."""
|
||||
|
||||
@tool(register=False)
|
||||
async def boom() -> str:
|
||||
return "error: boom"
|
||||
|
||||
reg = ToolRegistry([boom], danger=_danger, on_confirm=_allow)
|
||||
assert await reg.execute("boom", "{}") == "error: boom"
|
||||
|
||||
|
||||
async def test_a_handler_record_leads_the_result() -> None:
|
||||
@tool(register=False)
|
||||
async def policy_tool() -> str:
|
||||
record_approval(Approval("user", "process"))
|
||||
return "ran"
|
||||
|
||||
reg = ToolRegistry([policy_tool])
|
||||
assert await reg.execute("policy_tool", "{}") == "[approval] user (process)\nran"
|
||||
|
||||
|
||||
async def test_the_closest_answer_wins() -> None:
|
||||
"""YOLO let the call run, but a human answered a policy prompt inside it."""
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
|
||||
async def yolo_policy() -> str:
|
||||
record_approval(Approval("user", "process"))
|
||||
return "ran"
|
||||
|
||||
reg = ToolRegistry([yolo_policy], danger=_danger, on_confirm=_allow, yolo=True)
|
||||
assert await reg.execute("yolo_policy", "{}") == "[approval] user (process)\nran"
|
||||
|
||||
|
||||
async def test_the_line_survives_the_wire_cap() -> None:
|
||||
approved = f"{format_approval(Approval('user', 'once'))}\n{'x' * 500}"
|
||||
capped = cap_tool_message(ToolChatMessage(content=approved, tool_call_id="call-1"), 200)
|
||||
assert capped.content.startswith("[approval] user (once)\n")
|
||||
assert "document_id=call-1" in capped.content
|
||||
|
||||
|
||||
def test_the_most_specific_record_wins() -> None:
|
||||
records = [Approval("yolo", "auto"), Approval("grant", "session"), Approval("user", "once")]
|
||||
assert most_specific_approval(records) == Approval("user", "once")
|
||||
assert most_specific_approval([]) is None
|
||||
@@ -0,0 +1,71 @@
|
||||
"""A denial says which no it was: the human's, the clock's, or nobody's."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import Denial, DenialKind, ToolRegistry, tool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
def _danger(name: str, _args: Mapping[str, object]) -> str | None:
|
||||
return f"mutate {name}"
|
||||
|
||||
|
||||
@tool(register=False)
|
||||
async def mutate() -> str:
|
||||
return "ran"
|
||||
|
||||
|
||||
def _registry(answer: object) -> ToolRegistry:
|
||||
"""A registry whose one soft confirm answers *answer*."""
|
||||
return ToolRegistry([mutate], danger=_danger, on_confirm=lambda *_args: answer)
|
||||
|
||||
|
||||
async def test_a_declined_prompt_says_the_user_declined() -> None:
|
||||
out = await _registry(Denial(DenialKind.DECLINED)).execute("mutate", "{}")
|
||||
assert out.startswith("error: tool 'mutate' denied (")
|
||||
assert "user declined" in out
|
||||
assert "no answer" not in out
|
||||
|
||||
|
||||
async def test_a_timeout_names_its_deadline_instead_of_the_user() -> None:
|
||||
out = await _registry(Denial(DenialKind.TIMEOUT, seconds=30.0)).execute("mutate", "{}")
|
||||
assert "no answer within 30s — denied by default" in out
|
||||
assert "user declined" not in out
|
||||
|
||||
|
||||
async def test_a_cancelled_prompt_says_so() -> None:
|
||||
out = await _registry(Denial(DenialKind.CANCELLED)).execute("mutate", "{}")
|
||||
assert "prompt cancelled" in out
|
||||
|
||||
|
||||
async def test_no_prompt_says_nobody_was_asked() -> None:
|
||||
out = await _registry(Denial(DenialKind.NO_PROMPT)).execute("mutate", "{}")
|
||||
assert "no confirm hook or non-interactive" in out
|
||||
|
||||
|
||||
async def test_a_bare_comment_still_reads_as_a_declined_human() -> None:
|
||||
out = await _registry("my tax data").execute("mutate", "{}")
|
||||
assert "user declined" in out
|
||||
assert "user comment: my tax data" in out
|
||||
|
||||
|
||||
def test_a_timeout_without_a_deadline_still_says_default_deny() -> None:
|
||||
assert Denial(DenialKind.TIMEOUT).clause() == "no answer — denied by default"
|
||||
|
||||
|
||||
def test_the_denial_clause_never_claims_a_human() -> None:
|
||||
"""A timeout must not be reported as a decision, nor an absent prompt as one."""
|
||||
assert "user" not in Denial(DenialKind.TIMEOUT, seconds=5.0).clause()
|
||||
assert "user" not in Denial(DenialKind.NO_PROMPT).clause()
|
||||
|
||||
|
||||
def test_from_answer_keeps_a_denial_and_reads_a_comment() -> None:
|
||||
timeout = Denial(DenialKind.TIMEOUT, seconds=1.0)
|
||||
assert Denial.from_answer(timeout) is timeout
|
||||
assert Denial.from_answer("why not") == Denial(comment="why not")
|
||||
assert Denial.from_answer(None) == Denial(DenialKind.DECLINED)
|
||||
assert Denial.from_answer(False) == Denial(DenialKind.DECLINED)
|
||||
@@ -71,7 +71,7 @@ async def test_an_unknown_tool_is_error_text_without_images() -> None:
|
||||
|
||||
|
||||
async def test_the_loop_keeps_tool_images_on_the_tool_message() -> None:
|
||||
message, err = await _run_one_tool(_registry(), _call("shot"), max_result_chars=32_000)
|
||||
message, err = await _run_one_tool(_registry(), _call("shot"), hard_max_chars=32_000)
|
||||
|
||||
assert err is None
|
||||
assert isinstance(message, ToolChatMessage)
|
||||
@@ -80,16 +80,16 @@ async def test_the_loop_keeps_tool_images_on_the_tool_message() -> None:
|
||||
|
||||
|
||||
async def test_the_loop_leaves_the_images_field_unset_for_text_tools() -> None:
|
||||
message, err = await _run_one_tool(_registry(), _call("plain"), max_result_chars=32_000)
|
||||
message, err = await _run_one_tool(_registry(), _call("plain"), hard_max_chars=32_000)
|
||||
|
||||
assert err is None
|
||||
assert message.images is UNSET
|
||||
|
||||
|
||||
async def test_a_truncated_tool_result_keeps_its_images() -> None:
|
||||
message, _ = await _run_one_tool(_registry(), _call("shot"), max_result_chars=1)
|
||||
async def test_a_tool_result_above_the_storage_ceiling_keeps_its_images() -> None:
|
||||
message, _ = await _run_one_tool(_registry(), _call("shot"), hard_max_chars=1)
|
||||
|
||||
assert "[Truncated" in message.content
|
||||
assert "[Stored at the 1-char ceiling" in message.content
|
||||
assert message.images == [_image()]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""A tool's own shape check answers a call before anyone is asked about it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.agent import ToolRegistry
|
||||
from plyngent.tools.file.edit_lineno import edit_lineno
|
||||
from plyngent.tools.file.edit_replace import edit_replace
|
||||
from plyngent.tools.net.fetch_local import fetch_local
|
||||
from plyngent.tools.process.open_pty import open_pty
|
||||
from plyngent.tools.process.write_pty_keys import write_pty_keys
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
from plyngent.agent import ToolDefinition
|
||||
|
||||
|
||||
def _everything_needs_a_confirm(name: str, _args: Mapping[str, object]) -> str:
|
||||
"""A danger classifier that prompts for every call, so a suppressed prompt is visible."""
|
||||
return f"run {name}"
|
||||
|
||||
|
||||
def _registry(definition: ToolDefinition) -> tuple[ToolRegistry, list[str]]:
|
||||
asked: list[str] = []
|
||||
|
||||
def on_confirm(name: str, _args: Mapping[str, object], _reason: str) -> bool:
|
||||
asked.append(name)
|
||||
return True
|
||||
|
||||
return ToolRegistry([definition], danger=_everything_needs_a_confirm, on_confirm=on_confirm), asked
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("definition", "args", "expected"),
|
||||
[
|
||||
# A string where the tool needs an argv array would splat into characters.
|
||||
(open_pty, {"command": "ls -la"}, "only accepts an array of args"),
|
||||
# A typo in a <key> marker must not land in a shell prompt.
|
||||
(write_pty_keys, {"session_id": 1, "data": "<nope>"}, "unknown key <nope>"),
|
||||
(edit_replace, {"path": "a.py", "old_string": "", "new_string": "x"}, "old_string must not be empty"),
|
||||
(
|
||||
edit_replace,
|
||||
{"path": "a.py", "old_string": "x", "new_string": "y", "max_replaces": 0},
|
||||
"max_replaces must be >= 1",
|
||||
),
|
||||
(
|
||||
edit_lineno,
|
||||
{"path": "a.py", "start_line": 0, "end_line": 1, "new_content": "x"},
|
||||
"start_line must be >= 1",
|
||||
),
|
||||
(
|
||||
edit_lineno,
|
||||
{"path": "a.py", "start_line": 3, "end_line": 2, "new_content": "x"},
|
||||
"end_line must be >= start_line",
|
||||
),
|
||||
(fetch_local, {"url": "https://example.com", "method": "PATCH"}, "method must be one of"),
|
||||
(fetch_local, {"url": "ftp://example.com"}, "url scheme must be http or https"),
|
||||
(fetch_local, {"url": "https://example.com", "timeout_seconds": "soon"}, "timeout_seconds must be a number"),
|
||||
],
|
||||
)
|
||||
async def test_a_bad_argument_is_answered_before_the_prompt(
|
||||
definition: ToolDefinition,
|
||||
args: dict[str, object],
|
||||
expected: str,
|
||||
) -> None:
|
||||
registry, asked = _registry(definition)
|
||||
out = await registry.execute(definition.name, json.dumps(args))
|
||||
assert asked == []
|
||||
assert expected in out
|
||||
|
||||
|
||||
async def test_a_call_that_can_run_still_asks() -> None:
|
||||
"""The check answers only what it can judge; a runnable call keeps its prompt."""
|
||||
registry, asked = _registry(write_pty_keys)
|
||||
out = await registry.execute("write_pty_keys", json.dumps({"session_id": 1, "data": "<enter>"}))
|
||||
assert asked == ["write_pty_keys"]
|
||||
assert out.startswith(("[approval]", "error:"))
|
||||
@@ -26,6 +26,11 @@ def _danger(name: str, _args: Mapping[str, object]) -> str | None:
|
||||
return f"mutate {name}"
|
||||
|
||||
|
||||
def _approved(scope: str, text: str) -> str:
|
||||
"""A result as the model reads it once a confirmation let the call run."""
|
||||
return f"[approval] {scope}\n{text}"
|
||||
|
||||
|
||||
async def test_loop_round_prompts_once_before_running_its_calls() -> None:
|
||||
"""The loop aggregates a round's confirms and applies one answer to each call."""
|
||||
batch_prompts: list[int] = []
|
||||
@@ -54,7 +59,10 @@ async def test_loop_round_prompts_once_before_running_its_calls() -> None:
|
||||
|
||||
assert batch_prompts == [2]
|
||||
assert single_prompts == []
|
||||
assert [getattr(message, "content", None) for message in messages] == ["stamped a", "stamped b"]
|
||||
assert [getattr(message, "content", None) for message in messages] == [
|
||||
_approved("user (round)", "stamped a"),
|
||||
_approved("user (round)", "stamped b"),
|
||||
]
|
||||
assert len(events) == len(tool_calls) * 2 # call event + result event per call
|
||||
|
||||
|
||||
@@ -88,11 +96,51 @@ async def test_round_asks_once_for_many_mutating_calls() -> None:
|
||||
results = [
|
||||
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
|
||||
]
|
||||
assert results == ["stamped a", "stamped b", "stamped c"]
|
||||
assert results == [
|
||||
_approved("user (round)", "stamped a"),
|
||||
_approved("user (round)", "stamped b"),
|
||||
_approved("user (round)", "stamped c"),
|
||||
]
|
||||
assert ran == ["a", "b", "c"]
|
||||
assert single_prompts == []
|
||||
|
||||
|
||||
async def test_a_call_that_cannot_run_is_left_out_of_the_round_prompt() -> None:
|
||||
"""A tool's own shape check answers first, so the round prompt omits the call."""
|
||||
batch_prompts: list[list[ToolConfirmRequest]] = []
|
||||
|
||||
def positive(args: Mapping[str, object]) -> str | None:
|
||||
count = args.get("count")
|
||||
if isinstance(count, int) and count <= 0:
|
||||
return "error: count must be positive"
|
||||
return None
|
||||
|
||||
@tool(tags=ToolTag.LOCAL, register=False, validate=positive)
|
||||
async def scale(count: int = 1) -> str:
|
||||
return f"scaled {count}"
|
||||
|
||||
def on_batch(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
|
||||
batch_prompts.append(list(requests))
|
||||
return BatchConfirmDecision(allowed=True)
|
||||
|
||||
reg = ToolRegistry([scale], danger=_danger, on_confirm_batch=on_batch)
|
||||
calls = [("scale", '{"count": 2}'), ("scale", '{"count": 0}'), ("scale", '{"count": 3}')]
|
||||
decisions = await reg.prepare_batch_confirm(calls)
|
||||
|
||||
assert len(batch_prompts) == 1
|
||||
assert [request.args["count"] for request in batch_prompts[0]] == [2, 3]
|
||||
assert decisions[1] is None
|
||||
|
||||
results = [
|
||||
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
|
||||
]
|
||||
assert results == [
|
||||
_approved("user (round)", "scaled 2"),
|
||||
"error: count must be positive",
|
||||
_approved("user (round)", "scaled 3"),
|
||||
]
|
||||
|
||||
|
||||
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]] = []
|
||||
@@ -129,7 +177,11 @@ async def test_yolo_calls_are_left_out_of_the_prompt() -> None:
|
||||
results = [
|
||||
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
|
||||
]
|
||||
assert results == ["stamped a", "stamped b", "stamped c"]
|
||||
assert results == [
|
||||
_approved("yolo (auto)", "stamped a"),
|
||||
_approved("user (round)", "stamped b"),
|
||||
_approved("user (round)", "stamped c"),
|
||||
]
|
||||
assert single_prompts == []
|
||||
|
||||
|
||||
@@ -153,7 +205,7 @@ async def test_single_request_keeps_the_one_call_prompt() -> None:
|
||||
reg = ToolRegistry([stamp], danger=_danger, on_confirm=on_single, on_confirm_batch=on_batch)
|
||||
assert await reg.prepare_batch_confirm([("stamp", '{"path": "a"}')]) == [None]
|
||||
assert batch_prompts == []
|
||||
assert await reg.execute("stamp", '{"path": "a"}') == "stamped a"
|
||||
assert await reg.execute("stamp", '{"path": "a"}') == _approved("user (once)", "stamped a")
|
||||
assert single_prompts == ["stamp"]
|
||||
|
||||
|
||||
@@ -206,7 +258,8 @@ async def test_batch_deny_reaches_every_call_with_one_comment() -> None:
|
||||
results = [
|
||||
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
|
||||
]
|
||||
assert all(result.startswith("error: tool 'stamp' denied by user confirm") for result in results)
|
||||
assert all(result.startswith("error: tool 'stamp' denied (") for result in results)
|
||||
assert all("user declined" in result for result in results)
|
||||
assert all("user comment: not these two" in result for result in results)
|
||||
assert ran == []
|
||||
|
||||
@@ -254,7 +307,10 @@ async def test_manual_hands_the_round_back_to_the_per_call_prompt() -> None:
|
||||
decisions = await reg.prepare_batch_confirm(calls)
|
||||
|
||||
assert decisions == [None, None]
|
||||
assert [await reg.execute(name, arguments) for name, arguments in calls] == ["stamped a", "stamped b"]
|
||||
assert [await reg.execute(name, arguments) for name, arguments in calls] == [
|
||||
_approved("user (once)", "stamped a"),
|
||||
_approved("user (once)", "stamped b"),
|
||||
]
|
||||
assert batch_prompts == [2]
|
||||
assert single_prompts == ["stamp", "stamp"]
|
||||
|
||||
@@ -274,7 +330,10 @@ async def test_without_a_batch_hook_every_call_prompts_on_its_own() -> None:
|
||||
reg = ToolRegistry([stamp], danger=_danger, on_confirm=on_single)
|
||||
calls = [("stamp", '{"path": "a"}'), ("stamp", '{"path": "b"}')]
|
||||
assert await reg.prepare_batch_confirm(calls) == [None, None]
|
||||
assert [await reg.execute(name, arguments) for name, arguments in calls] == ["stamped a", "stamped b"]
|
||||
assert [await reg.execute(name, arguments) for name, arguments in calls] == [
|
||||
_approved("user (once)", "stamped a"),
|
||||
_approved("user (once)", "stamped b"),
|
||||
]
|
||||
assert single_prompts == ["stamp", "stamp"]
|
||||
|
||||
|
||||
@@ -306,8 +365,9 @@ async def test_batch_allow_records_the_trustable_grant() -> None:
|
||||
calls = [("trust_me", '{"path": "a"}'), ("trust_me", '{"path": "b"}')]
|
||||
decisions = await reg.prepare_batch_confirm(calls)
|
||||
for path, decision in zip(("a", "b"), decisions, strict=True):
|
||||
assert await reg.execute("trust_me", f'{{"path": "{path}"}}', decision=decision) == f"done {path}"
|
||||
approve = _approved("user (round)", f"done {path}")
|
||||
assert await reg.execute("trust_me", f'{{"path": "{path}"}}', decision=decision) == approve
|
||||
assert has_grant(session, "trust_me")
|
||||
# The TRUSTABLE grant, not the batch answer, keeps later calls unchecked.
|
||||
assert await reg.execute("trust_me", '{"path": "c"}') == "done c"
|
||||
assert await reg.execute("trust_me", '{"path": "c"}') == _approved("grant (session)", "done c")
|
||||
assert single_prompts == []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING, Literal, overload
|
||||
|
||||
import pytest
|
||||
@@ -57,6 +58,31 @@ class SummaryClient:
|
||||
)
|
||||
|
||||
|
||||
class HangingClient:
|
||||
"""A summarizer that never answers until the request is cancelled."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.entered = asyncio.Event()
|
||||
|
||||
@overload
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
|
||||
) -> ChatCompletionResponse: ...
|
||||
|
||||
@overload
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: Literal[True]
|
||||
) -> AsyncIterator[ChatCompletionChunk]: ...
|
||||
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||
del param, stream
|
||||
self.entered.set()
|
||||
await asyncio.Event().wait() # until the caller cancels
|
||||
raise AssertionError("the summary request should have been cancelled")
|
||||
|
||||
|
||||
async def test_compact_to_new_session(tmp_path: Path) -> None:
|
||||
memory = await MemoryStore.open(DatabaseConfig())
|
||||
try:
|
||||
@@ -128,6 +154,50 @@ async def test_compact_to_new_session(tmp_path: Path) -> None:
|
||||
await memory.close()
|
||||
|
||||
|
||||
async def test_compact_cancel_leaves_the_session_untouched(tmp_path: Path) -> None:
|
||||
"""Ctrl+C during the summary drops the request before anything is created."""
|
||||
memory = await MemoryStore.open(DatabaseConfig())
|
||||
try:
|
||||
provider = OpenAIProvider(access_key_or_token="sk-test")
|
||||
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
|
||||
config.providers = {"local": provider}
|
||||
state = ReplState(
|
||||
config=config,
|
||||
memory=memory,
|
||||
workspace=tmp_path,
|
||||
provider_name="local",
|
||||
provider=provider,
|
||||
model="gpt-test",
|
||||
tools_enabled=False,
|
||||
)
|
||||
await state.new_session("orig")
|
||||
old_id = state.session_id
|
||||
assert old_id is not None
|
||||
client = HangingClient()
|
||||
state.client = client
|
||||
state.agent = ChatAgent(
|
||||
state.client,
|
||||
model=state.model,
|
||||
memory=state.memory,
|
||||
session_id=old_id,
|
||||
system_prompt="Be brief.",
|
||||
)
|
||||
state.agent.messages = [UserChatMessage(content="do work")]
|
||||
before = {row.sid for row in await memory.list_sessions(workspace=tmp_path)}
|
||||
|
||||
task = asyncio.ensure_future(state.compact_to_new_session())
|
||||
await asyncio.wait_for(client.entered.wait(), timeout=5)
|
||||
_ = task.cancel()
|
||||
with pytest.raises(ValueError, match="compaction cancelled"):
|
||||
await task
|
||||
|
||||
assert state.session_id == old_id
|
||||
after = {row.sid for row in await memory.list_sessions(workspace=tmp_path)}
|
||||
assert after == before
|
||||
finally:
|
||||
await memory.close()
|
||||
|
||||
|
||||
async def test_rebuild_client_preserves_persist_cursor(tmp_path: Path) -> None:
|
||||
memory = await MemoryStore.open(DatabaseConfig())
|
||||
try:
|
||||
|
||||
@@ -456,9 +456,9 @@ async def test_pretty_run_argv_batch_stopped(capsys: pytest.CaptureFixture[str])
|
||||
result = (
|
||||
"steps=3 ran=2 stop_on_error=true stopped_early=true\n"
|
||||
"--- step 1 ---\nexit_code=1\ntimed_out=false\ncwd=.\ncmd=git status\n"
|
||||
"pipe_out=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
|
||||
"pipe=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
|
||||
"--- step 2 ---\nexit_code=0\ntimed_out=false\ncwd=.\ncmd=git diff\n"
|
||||
"pipe_out=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
|
||||
"pipe=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
|
||||
"--- summary ---\nlast_exit=1\n"
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("run_argv_batch", '{"steps": []}'), _result(result)]))
|
||||
@@ -495,16 +495,27 @@ async def test_pretty_run_argv_batch_lists_commands(capsys: pytest.CaptureFixtur
|
||||
|
||||
|
||||
async def test_pretty_run_argv_batch_joins_pipe_chain(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""``pipe_out`` steps share one line; a step they do not feed keeps its own."""
|
||||
"""A pipe chain shares one line; a node it does not feed keeps its own."""
|
||||
args = (
|
||||
'{"steps": [{"argv": ["grep", "-n", "foo", "a.py"], "pipe_out": true},'
|
||||
' {"argv": ["wc", "-l"]}, {"argv": ["git", "status"]}]}'
|
||||
'{"steps": [{"argv": ["grep", "-n", "foo", "a.py"], "pipe": {"argv": ["wc", "-l"]}},'
|
||||
' {"argv": ["git", "status"]}]}'
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("run_argv_batch", args), _result(_BATCH_DONE_RESULT)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "$ grep -n foo a.py | wc -l\n$ git status\n* Run batch (done)" in out
|
||||
|
||||
|
||||
async def test_pretty_run_argv_batch_indents_branches(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A ``then`` / ``else`` body sits below the node it follows."""
|
||||
args = (
|
||||
'{"steps": [{"argv": ["pytest", "-q"], "then": {"argv": ["ruff", "format", "."]},'
|
||||
' "else": [{"argv": ["git", "status"]}]}]}'
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("run_argv_batch", args), _result(_BATCH_DONE_RESULT)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "$ pytest -q\n then ruff format .\n else git status\n* Run batch (done)" in out
|
||||
|
||||
|
||||
async def test_pretty_run_argv_batch_elides_a_long_command(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
long_arg = "x" * 200
|
||||
args = f'{{"steps": [{{"argv": ["echo", "{long_arg}"]}}]}}'
|
||||
@@ -1018,6 +1029,17 @@ def test_every_builtin_tool_has_a_pretty_line() -> None:
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_a_confirmed_result_keeps_its_pretty_detail() -> None:
|
||||
"""The approval line leads the model's text; a pretty detail reads past it."""
|
||||
from plyngent.cli.display import _pretty_line_for, _pretty_parts
|
||||
|
||||
line = _pretty_line_for("write_file")
|
||||
assert line is not None
|
||||
approved = "[approval] user (once)\nwrote 120 characters to a.txt"
|
||||
_prefix, detail = _pretty_parts(line, '{"path": "a.txt", "overwrite": true}', approved)
|
||||
assert "(120 chars)" in detail
|
||||
|
||||
|
||||
async def test_pretty_skill_tools(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(
|
||||
_aiter(
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
|
||||
import pytest
|
||||
import tomlkit
|
||||
|
||||
from plyngent.agent import Denial, DenialKind
|
||||
from plyngent.cli.slash import handle_slash
|
||||
from plyngent.cli.state import ReplState
|
||||
from plyngent.config.models import DatabaseConfig, OpenAIProvider
|
||||
@@ -16,6 +17,7 @@ from plyngent.memory import MemoryStore
|
||||
from plyngent.prompting import NonInteractiveBackend, temporary_backend
|
||||
from plyngent.tools import AccessMode, grant_session_access
|
||||
from plyngent.tools.access import AccessDecision
|
||||
from tests.test_prompting import ScriptedBackend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
@@ -131,15 +133,30 @@ async def test_persist_and_load_roundtrip(state: ReplState, tmp_path: Path) -> N
|
||||
assert state.session_state.access_grants == {outside: AccessMode.WRITE}
|
||||
|
||||
|
||||
def test_yolo_hook_auto_approves_as_process_grant(state: ReplState, tmp_path: Path) -> None:
|
||||
def test_yolo_does_not_answer_the_directory_access_prompt(
|
||||
state: ReplState,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""An out-of-workspace grant is ask-only, whatever YOLO mode says."""
|
||||
from plyngent.cli import limits
|
||||
|
||||
state.set_yolo("on")
|
||||
decision = state.directory_access_confirm_hook(tmp_path, AccessMode.EXEC, "", 30.0)
|
||||
assert decision == AccessDecision(AccessMode.EXEC, persist=False)
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "y\n")
|
||||
with temporary_backend(ScriptedBackend([])):
|
||||
decision = state.directory_access_confirm_hook(tmp_path, AccessMode.EXEC, "", 30.0)
|
||||
# Answered as a human prompt, so it is a session grant — not a process grant.
|
||||
assert decision == AccessDecision(AccessMode.EXEC, persist=True)
|
||||
|
||||
|
||||
def test_default_hook_denies_noninteractive(state: ReplState, tmp_path: Path) -> None:
|
||||
def test_yolo_denies_the_directory_access_prompt_when_nobody_answers(
|
||||
state: ReplState,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
state.set_yolo("on")
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
assert state.directory_access_confirm_hook(tmp_path, AccessMode.READ, "", 1.0) is None
|
||||
answer = state.directory_access_confirm_hook(tmp_path, AccessMode.READ, "", 1.0)
|
||||
assert answer == Denial(DenialKind.NO_PROMPT)
|
||||
|
||||
|
||||
async def test_config_allow_paths_installed(
|
||||
|
||||
@@ -326,3 +326,33 @@ async def test_sleep_cancellable_cancelled_by_sigint(router: None) -> None:
|
||||
await asyncio.sleep(0)
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
assert await asyncio.wait_for(waiter, timeout=5) is False
|
||||
|
||||
|
||||
def test_a_second_loop_rebinds_the_router() -> None:
|
||||
"""The installed flag is process-wide, so a new loop needs its own handler.
|
||||
|
||||
asyncio drops a loop's signal handlers with the loop, so believing the flag
|
||||
alone left the next loop unrouted: a stray Ctrl+C then became a
|
||||
KeyboardInterrupt out of the loop's own frame instead of cancelling work.
|
||||
"""
|
||||
|
||||
class Counter:
|
||||
hits = 0
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.hits += 1
|
||||
|
||||
async def routed_once() -> int:
|
||||
assert install_sigint_router()
|
||||
target = Counter()
|
||||
with sigint_cancels(target):
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
await asyncio.sleep(0.05)
|
||||
return target.hits
|
||||
|
||||
install_keyboard_interrupt_sigint()
|
||||
try:
|
||||
assert asyncio.run(routed_once()) == 1
|
||||
assert asyncio.run(routed_once()) == 1
|
||||
finally:
|
||||
uninstall_sigint_router()
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.agent import Denial, DenialKind
|
||||
from plyngent.cli.display import close_open_pretty_line
|
||||
from plyngent.cli.limits import (
|
||||
auto_continue_enabled,
|
||||
@@ -130,7 +131,8 @@ def test_pty_offer_raise_uses_auto_continue(clean_auto_continue: None) -> None:
|
||||
def test_prompt_confirm_tool_default_deny() -> None:
|
||||
backend = ScriptedBackend([], confirms=[False])
|
||||
with temporary_backend(backend):
|
||||
assert prompt_confirm_tool("delete_path", {"path": "x"}, "delete path 'x'") is False
|
||||
answer = prompt_confirm_tool("delete_path", {"path": "x"}, "delete path 'x'")
|
||||
assert answer == Denial(DenialKind.DECLINED)
|
||||
|
||||
|
||||
def test_format_tool_confirm_box_multiline() -> None:
|
||||
@@ -159,7 +161,7 @@ def test_policy_confirm_noninteractive_denies() -> None:
|
||||
from plyngent.prompting import NonInteractiveBackend
|
||||
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
assert prompt_policy_command_confirm("sudo", ["sudo", "id"], 1.0) is False
|
||||
assert prompt_policy_command_confirm("sudo", ["sudo", "id"], 1.0) == Denial(DenialKind.NO_PROMPT)
|
||||
|
||||
|
||||
def test_directory_access_prompt_noninteractive_denies() -> None:
|
||||
@@ -170,7 +172,9 @@ def test_directory_access_prompt_noninteractive_denies() -> None:
|
||||
from plyngent.tools.workspace import AccessMode
|
||||
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
assert prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 1.0) is None
|
||||
assert prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 1.0) == Denial(
|
||||
DenialKind.NO_PROMPT
|
||||
)
|
||||
|
||||
|
||||
def test_directory_access_prompt_yes_and_level(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -191,9 +195,14 @@ def test_directory_access_prompt_yes_and_level(monkeypatch: pytest.MonkeyPatch)
|
||||
AccessMode.EXEC
|
||||
)
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "n\n")
|
||||
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) is None
|
||||
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == Denial(
|
||||
DenialKind.DECLINED
|
||||
)
|
||||
# Silence is its own answer: the deadline is named, not the human.
|
||||
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: None)
|
||||
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) is None
|
||||
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == Denial(
|
||||
DenialKind.TIMEOUT, seconds=30.0
|
||||
)
|
||||
|
||||
|
||||
class _AnswerSource:
|
||||
@@ -243,7 +252,9 @@ def test_directory_access_denied_by_the_notification() -> None:
|
||||
from plyngent.tools.workspace import AccessMode
|
||||
|
||||
with temporary_backend(ScriptedBackend([])), _answered("deny"):
|
||||
assert limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0) is None
|
||||
assert limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0) == Denial(
|
||||
DenialKind.DECLINED
|
||||
)
|
||||
|
||||
|
||||
def test_tool_confirm_takes_a_notification_answer() -> None:
|
||||
@@ -253,7 +264,7 @@ def test_tool_confirm_takes_a_notification_answer() -> None:
|
||||
|
||||
def test_tool_confirm_denied_by_the_notification() -> None:
|
||||
with temporary_backend(ScriptedBackend([""])), _answered("deny"):
|
||||
assert prompt_confirm_tool("run_argv", {}, "runs a command") is False
|
||||
assert prompt_confirm_tool("run_argv", {}, "runs a command") == Denial(DenialKind.DECLINED)
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="a pty is POSIX-only")
|
||||
@@ -396,6 +407,15 @@ def test_a_limit_prompt_can_stop_the_turn_from_the_notification() -> None:
|
||||
assert limits.prompt_continue_limit("hit a wall") is False
|
||||
|
||||
|
||||
def test_a_limit_prompt_still_names_its_answers_at_the_terminal(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Buttons elsewhere do not say what typing here would do."""
|
||||
from plyngent.cli import limits
|
||||
|
||||
with temporary_backend(ScriptedBackend([])), _answered("y"):
|
||||
assert limits.prompt_continue_limit("hit a wall") is True
|
||||
assert "[Y/n/yyy]" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_a_skill_write_can_be_allowed_for_the_chat_from_the_notification() -> None:
|
||||
"""``s`` (this chat) is a button, not only a terminal token."""
|
||||
from pathlib import Path
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from plyngent.agent import BatchConfirmDecision, ToolConfirmRequest
|
||||
from plyngent.agent import BatchConfirmDecision, Denial, DenialKind, ToolConfirmRequest
|
||||
from plyngent.cli.limits import (
|
||||
_prompt_confirm_batch_sync,
|
||||
format_batch_confirm_box,
|
||||
@@ -69,13 +69,13 @@ def test_batch_prompt_denies_with_a_comment() -> None:
|
||||
def test_batch_prompt_defaults_to_deny() -> None:
|
||||
with temporary_backend(ScriptedBackend([])):
|
||||
decision = _prompt_confirm_batch_sync(_requests())
|
||||
assert decision == BatchConfirmDecision(allowed=False)
|
||||
assert decision == BatchConfirmDecision(allowed=False, denial=Denial(DenialKind.DECLINED))
|
||||
|
||||
|
||||
def test_batch_prompt_denies_when_not_interactive() -> None:
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
decision = _prompt_confirm_batch_sync(_requests())
|
||||
assert decision == BatchConfirmDecision(allowed=False)
|
||||
assert decision == BatchConfirmDecision(allowed=False, denial=Denial(DenialKind.NO_PROMPT))
|
||||
|
||||
|
||||
async def test_batch_prompt_async_allows() -> None:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from typing import TYPE_CHECKING, Literal, overload
|
||||
from typing import TYPE_CHECKING, Literal, cast, overload
|
||||
|
||||
import pytest
|
||||
import tomlkit
|
||||
@@ -28,7 +29,7 @@ from plyngent.tools import image_input_refusal
|
||||
from plyngent.tools.context import bind_instance
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -110,6 +111,21 @@ async def test_help(state: ReplState) -> None:
|
||||
assert await handle_slash(state, "/help") is True
|
||||
|
||||
|
||||
async def test_a_cancelled_slash_command_keeps_the_repl(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A command's own work being cancelled must not cancel the REPL's task."""
|
||||
from plyngent.cli import slash as slash_module
|
||||
|
||||
def cancelled(_args: Sequence[str], _state: object) -> None:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setattr(slash_module, "_run_slash_argv", cancelled)
|
||||
assert await slash_module.handle_slash(cast("ReplState", cast("object", None)), "/btw hello") is True
|
||||
assert "cancelled" in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_auto_continue_limits_installs_on_limit(tmp_path: Path) -> None:
|
||||
from plyngent.cli.limits import auto_continue_enabled, reset_auto_continue
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ def test_agent_section_defaults(tmp_path: Path) -> None:
|
||||
assert "professional coding agent" in store.agent_config.system_prompt
|
||||
assert "### Workspace" in store.agent_config.tool_directives
|
||||
assert store.agent_config.max_tool_result_chars == 32_000
|
||||
assert store.agent_config.hard_max_tool_result_chars == 2_000_000
|
||||
assert store.agent_config.parallel_tools is True
|
||||
assert store.agent_config.confirm_destructive is True
|
||||
assert store.agent_config.path_denylist == []
|
||||
|
||||
@@ -123,7 +123,24 @@ async def test_deny_none(
|
||||
outside = tmp_path_factory.mktemp("timeout").resolve()
|
||||
set_directory_access_confirm_hook(lambda *_a: None)
|
||||
out = await call_async(request_directory_access, str(outside))
|
||||
assert "denied (declined or timed out" in out
|
||||
assert "denied (user declined)" in out
|
||||
assert session.access_grants == {}
|
||||
|
||||
|
||||
async def test_a_prompt_timeout_is_not_reported_as_a_decision(
|
||||
workspace: object,
|
||||
session: SessionState,
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
) -> None:
|
||||
"""The deadline the prompt waited out is what the model reads."""
|
||||
from plyngent.agent import Denial, DenialKind
|
||||
|
||||
del workspace
|
||||
outside = tmp_path_factory.mktemp("silence").resolve()
|
||||
set_directory_access_confirm_hook(lambda *_a: Denial(DenialKind.TIMEOUT, seconds=12.0))
|
||||
out = await call_async(request_directory_access, str(outside))
|
||||
assert "denied (no answer within 12s — denied by default)" in out
|
||||
assert "user" not in out
|
||||
assert session.access_grants == {}
|
||||
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ def test_read_only_classification() -> None:
|
||||
"read_file",
|
||||
"view_image",
|
||||
"get_truncated",
|
||||
"read_document",
|
||||
"search_document",
|
||||
"document_info",
|
||||
"list_documents",
|
||||
"glob_paths",
|
||||
"regex_files",
|
||||
"listdir",
|
||||
|
||||
@@ -33,9 +33,9 @@ async def test_yolo_only_skips_yolo_tagged_tools() -> None:
|
||||
on_confirm=confirm,
|
||||
yolo=True,
|
||||
)
|
||||
assert await reg.execute("yolo_ok", "{}") == "y"
|
||||
assert await reg.execute("yolo_ok", "{}") == "[approval] yolo (auto)\ny"
|
||||
assert calls == []
|
||||
assert await reg.execute("no_yolo", "{}") == "n"
|
||||
assert await reg.execute("no_yolo", "{}") == "[approval] user (once)\nn"
|
||||
assert calls == ["no_yolo"]
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ async def test_yolo_off_keeps_the_yolo_tagged_prompt() -> None:
|
||||
return True
|
||||
|
||||
reg = ToolRegistry([yolo_ok], danger=danger, on_confirm=confirm, yolo=False)
|
||||
assert await reg.execute("yolo_ok", "{}") == "y"
|
||||
assert await reg.execute("yolo_ok", "{}") == "[approval] user (once)\ny"
|
||||
assert calls == ["yolo_ok"]
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ async def test_yolo_keeps_a_reason_that_refuses_the_skip() -> None:
|
||||
return True
|
||||
|
||||
reg = ToolRegistry([escalated], danger=danger, on_confirm=confirm, yolo=True)
|
||||
assert await reg.execute("escalated", "{}") == "x"
|
||||
assert await reg.execute("escalated", "{}") == "[approval] user (once)\nx"
|
||||
assert seen == ["interpreter 'python' + privilege escalation sudo"]
|
||||
|
||||
|
||||
@@ -103,8 +103,10 @@ async def test_trustable_grant_once() -> None:
|
||||
session_state=session,
|
||||
)
|
||||
with bind_tool_context(session=session):
|
||||
assert await reg.execute("trust_me", "{}") == "ok"
|
||||
assert await reg.execute("trust_me", "{}") == "ok"
|
||||
assert await reg.execute("trust_me", "{}") == "[approval] user (once)\nok"
|
||||
# The TRUSTABLE grant answers from here on: nobody is asked, and the
|
||||
# result says so.
|
||||
assert await reg.execute("trust_me", "{}") == "[approval] grant (session)\nok"
|
||||
assert calls == ["trust_me"]
|
||||
|
||||
|
||||
@@ -123,8 +125,8 @@ async def test_untagged_trustable_prompts_every_time() -> None:
|
||||
return True
|
||||
|
||||
reg = ToolRegistry([always_ask], danger=danger, on_confirm=confirm, yolo=False)
|
||||
assert await reg.execute("always_ask", "{}") == "ok"
|
||||
assert await reg.execute("always_ask", "{}") == "ok"
|
||||
assert await reg.execute("always_ask", "{}") == "[approval] user (once)\nok"
|
||||
assert await reg.execute("always_ask", "{}") == "[approval] user (once)\nok"
|
||||
assert calls == ["always_ask", "always_ask"]
|
||||
|
||||
|
||||
@@ -151,7 +153,7 @@ async def test_trustable_grant_persists_to_session_data() -> None:
|
||||
yolo=False,
|
||||
session_state=session,
|
||||
)
|
||||
assert await reg.execute("trust_me", "{}") == "ok"
|
||||
assert await reg.execute("trust_me", "{}") == "[approval] user (once)\nok"
|
||||
assert has_grant(session, "trust_me")
|
||||
loaded = await store.load()
|
||||
assert isinstance(loaded, dict)
|
||||
@@ -170,7 +172,7 @@ async def test_trustable_grant_persists_to_session_data() -> None:
|
||||
yolo=False,
|
||||
session_state=session2,
|
||||
)
|
||||
assert await reg2.execute("trust_me", "{}") == "ok"
|
||||
assert await reg2.execute("trust_me", "{}") == "[approval] grant (session)\nok"
|
||||
assert calls == ["trust_me"]
|
||||
|
||||
|
||||
|
||||
@@ -108,6 +108,34 @@ def test_classify_run_argv_batch_risky() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_classify_run_argv_batch_sees_nested_steps() -> None:
|
||||
"""A command hiding in a continuation is reviewed like a top-level step."""
|
||||
reason = _danger(
|
||||
"run_argv_batch",
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"argv": ["echo", "ok"],
|
||||
"then": {"argv": ["bash", "-c", "echo risky"]},
|
||||
"else": [{"argv": ["sh", "-c", "echo risky"]}],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert "run_argv_batch[0].then[0]" in reason.text
|
||||
assert "run_argv_batch[0].else[0]" in reason.text
|
||||
# A plain interpreter run is YOLO mode's to drop, wherever it sits.
|
||||
assert reason.yolo_skip is True
|
||||
|
||||
|
||||
def test_a_nested_step_that_escalates_keeps_the_batch_prompt() -> None:
|
||||
reason = _danger(
|
||||
"run_argv_batch",
|
||||
{"steps": [{"argv": ["echo", "ok"], "then": {"argv": ["sudo", "python", "-c", "print(1)"]}}]},
|
||||
)
|
||||
assert reason.yolo_skip is False
|
||||
|
||||
|
||||
def test_classify_shell_and_dash_c() -> None:
|
||||
r = _reason("run_argv", {"argv": ["bash", "-c", "rm -rf /"]})
|
||||
assert r is not None
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Document tools: read/search/list the tool results the request-prep pass capped."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import msgspec
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AnyChatMessage,
|
||||
AssistantChatMessage,
|
||||
AssistantFunctionTool,
|
||||
AssistantFunctionToolCall,
|
||||
ToolChatMessage,
|
||||
UserChatMessage,
|
||||
)
|
||||
from plyngent.tools.context import InstanceState, bind_instance
|
||||
from plyngent.tools.documents import DocumentStore
|
||||
from plyngent.tools.file import document_info, list_documents, read_document, search_document
|
||||
from tests.test_tools.helpers import call_async
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator, Sequence
|
||||
|
||||
|
||||
def _history(text: str, *, tool_call_id: str = "call-1", tool_name: str = "some_tool") -> list[AnyChatMessage]:
|
||||
return [
|
||||
UserChatMessage(content="go"),
|
||||
AssistantChatMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(
|
||||
id=tool_call_id,
|
||||
function=AssistantFunctionTool(name=tool_name, arguments="{}"),
|
||||
)
|
||||
],
|
||||
),
|
||||
ToolChatMessage(content=text, tool_call_id=tool_call_id),
|
||||
]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bound_document(
|
||||
text: str,
|
||||
*,
|
||||
tool_call_id: str = "call-1",
|
||||
tool_name: str = "some_tool",
|
||||
) -> Generator[DocumentStore]:
|
||||
"""Bind an instance whose document store reads one tool result."""
|
||||
messages = _history(text, tool_call_id=tool_call_id, tool_name=tool_name)
|
||||
instance = InstanceState()
|
||||
store = DocumentStore(lambda: messages)
|
||||
instance.extras["documents"] = store
|
||||
with bind_instance(instance):
|
||||
yield store
|
||||
|
||||
|
||||
_TEN_LINES = "".join(f"line {i}\n" for i in range(1, 11))
|
||||
|
||||
|
||||
async def test_read_document_without_store(workspace: object) -> None:
|
||||
del workspace
|
||||
out = await call_async(read_document, "call-1")
|
||||
assert out.startswith("error:")
|
||||
|
||||
|
||||
async def test_read_document_unknown_id() -> None:
|
||||
with bound_document(_TEN_LINES):
|
||||
out = await call_async(read_document, "nope")
|
||||
assert out.startswith("error:")
|
||||
assert "unknown document" in out
|
||||
|
||||
|
||||
async def test_read_document_window_and_range() -> None:
|
||||
with bound_document(_TEN_LINES):
|
||||
window = await call_async(read_document, "call-1", offset=0, limit=3)
|
||||
whole = await call_async(read_document, "call-1")
|
||||
mid = await call_async(read_document, "call-1", offset=2, limit=2)
|
||||
assert window == "L1-3\nline 1\nline 2\nline 3\n[... more: next offset=3]"
|
||||
assert whole == f"L1-10\n{_TEN_LINES}"
|
||||
assert "next offset" not in whole
|
||||
assert mid.startswith("L3-4\nline 3\nline 4\n")
|
||||
|
||||
|
||||
async def test_read_document_numbered() -> None:
|
||||
with bound_document(_TEN_LINES):
|
||||
out = await call_async(read_document, "call-1", offset=1, limit=2, with_lineno=True)
|
||||
assert out.startswith(" 2|line 2\n 3|line 3\n")
|
||||
|
||||
|
||||
async def test_read_document_cut_names_next_offset() -> None:
|
||||
text = "".join(f"a very long line number {i}\n" for i in range(1, 21))
|
||||
with bound_document(text):
|
||||
out = await call_async(read_document, "call-1", max_chars=40)
|
||||
assert "next offset=" in out
|
||||
offset = int(out.rsplit("next offset=", 1)[1].split("]", 1)[0])
|
||||
nxt = await call_async(read_document, "call-1", offset=offset)
|
||||
assert nxt.startswith(f"L{offset + 1}-")
|
||||
|
||||
|
||||
async def test_read_document_offset_past_end() -> None:
|
||||
with bound_document(_TEN_LINES):
|
||||
out = await call_async(read_document, "call-1", offset=99)
|
||||
assert out == ""
|
||||
|
||||
|
||||
async def test_search_document_hits_and_miss() -> None:
|
||||
with bound_document(_TEN_LINES):
|
||||
hits = await call_async(search_document, "call-1", r"line \d+")
|
||||
miss = await call_async(search_document, "call-1", "absent")
|
||||
assert hits.splitlines()[0] == "L1: line 1"
|
||||
assert "L10: line 10" in hits
|
||||
assert miss == "(no matches)"
|
||||
|
||||
|
||||
async def test_search_document_context_and_cap() -> None:
|
||||
with bound_document(_TEN_LINES):
|
||||
block = await call_async(search_document, "call-1", "line 5", context_lines=1)
|
||||
capped = await call_async(search_document, "call-1", r"line \d+", max_matches=2)
|
||||
assert block == "L4: line 4\nL5: line 5\nL6: line 6"
|
||||
assert "...[truncated at 2 matches]" in capped
|
||||
|
||||
|
||||
async def test_search_document_invalid_regex() -> None:
|
||||
with bound_document(_TEN_LINES):
|
||||
out = await call_async(search_document, "call-1", "(")
|
||||
assert out.startswith("error: invalid regex")
|
||||
|
||||
|
||||
async def test_document_info_and_list() -> None:
|
||||
with bound_document(_TEN_LINES, tool_name="run_argv"):
|
||||
info = await call_async(document_info, "call-1")
|
||||
listing = await call_async(list_documents)
|
||||
assert "tool=run_argv" in info
|
||||
assert "lines=10" in info
|
||||
assert "first=line 1" in info
|
||||
assert "last=line 10" in info
|
||||
assert "document_id=call-1 tool=run_argv" in listing
|
||||
assert "chars=" in listing
|
||||
|
||||
|
||||
async def test_list_documents_without_store(workspace: object) -> None:
|
||||
del workspace
|
||||
assert (await call_async(list_documents)).startswith("error:")
|
||||
|
||||
|
||||
async def test_document_survives_serialization_roundtrip() -> None:
|
||||
"""A resume reloads messages from JSON; the store still resolves the id."""
|
||||
messages: Sequence[AnyChatMessage] = _history(_TEN_LINES)
|
||||
reloaded: list[AnyChatMessage] = [
|
||||
msgspec.convert(msgspec.to_builtins(message), type=AnyChatMessage) for message in messages
|
||||
]
|
||||
instance = InstanceState()
|
||||
instance.extras["documents"] = DocumentStore(lambda: reloaded)
|
||||
with bind_instance(instance):
|
||||
out = await call_async(read_document, "call-1", offset=0, limit=1)
|
||||
assert out.startswith("L1-1\nline 1\n")
|
||||
@@ -23,7 +23,6 @@ from plyngent.tools.net.policy import (
|
||||
parse_fetch_url,
|
||||
soft_confirm_reason,
|
||||
)
|
||||
from plyngent.tools.truncate_token import decode_truncate_token
|
||||
from tests.test_tools.helpers import call_async
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -189,6 +188,50 @@ def test_classify_danger_fetch() -> None:
|
||||
assert isinstance(reason2, str) and "DELETE" in reason2
|
||||
|
||||
|
||||
async def test_a_private_host_answer_is_recorded_as_a_user_process_approval(workspace: Path) -> None:
|
||||
"""The prompt that opened loopback for this process is what the result says."""
|
||||
del workspace
|
||||
from plyngent.agent import Approval
|
||||
from plyngent.tools.context import collect_approvals
|
||||
from plyngent.tools.net.grants import ensure_host_allowed, set_fetch_policy_confirm_hook
|
||||
|
||||
set_fetch_policy_confirm_hook(lambda *_args: True)
|
||||
try:
|
||||
with collect_approvals() as recorded:
|
||||
await ensure_host_allowed("http://127.0.0.1:9/")
|
||||
finally:
|
||||
set_fetch_policy_confirm_hook(None)
|
||||
assert recorded == [Approval("user", "process")]
|
||||
|
||||
|
||||
async def test_a_private_host_timeout_names_the_deadline(workspace: Path) -> None:
|
||||
"""A prompt that nobody answered says the clock, not a human's no."""
|
||||
del workspace
|
||||
from plyngent.agent import Denial, DenialKind
|
||||
from plyngent.tools.net.grants import ensure_host_allowed, set_fetch_policy_confirm_hook
|
||||
from plyngent.tools.net.policy import FetchPolicyError
|
||||
|
||||
set_fetch_policy_confirm_hook(lambda *_args: Denial(DenialKind.TIMEOUT, seconds=7.0))
|
||||
try:
|
||||
with pytest.raises(FetchPolicyError, match="no answer within 7s"):
|
||||
await ensure_host_allowed("http://127.0.0.1:9/")
|
||||
finally:
|
||||
set_fetch_policy_confirm_hook(None)
|
||||
|
||||
|
||||
async def test_a_private_host_grant_is_recorded_as_a_process_grant(workspace: Path) -> None:
|
||||
"""A second fetch to the granted host asks nobody, and says so."""
|
||||
del workspace
|
||||
from plyngent.agent import Approval
|
||||
from plyngent.tools.context import collect_approvals
|
||||
from plyngent.tools.net.grants import ensure_host_allowed
|
||||
|
||||
grant_private_host("127.0.0.1", 9)
|
||||
with collect_approvals() as recorded:
|
||||
await ensure_host_allowed("http://127.0.0.1:9/")
|
||||
assert recorded == [Approval("grant", "process")]
|
||||
|
||||
|
||||
async def test_fetch_get_post_put_delete(workspace: Path, http_server: str) -> None:
|
||||
del workspace
|
||||
base = http_server
|
||||
@@ -299,37 +342,30 @@ async def test_fetch_truncation_and_binary(workspace: Path, http_server: str) ->
|
||||
assert "binary body omitted" in out_bin
|
||||
|
||||
|
||||
async def test_fetch_char_truncation_embeds_token(workspace: Path, http_server: str) -> None:
|
||||
del workspace
|
||||
port = int(http_server.rsplit(":", 1)[1])
|
||||
grant_private_host("127.0.0.1", port)
|
||||
|
||||
out = await call_async(fetch_local, f"{http_server}/big", max_chars=1000)
|
||||
assert "truncated=true" in out
|
||||
body = out.split("--- body ---", 1)[-1]
|
||||
assert "truncate_token=" in body
|
||||
token_line = next(ln for ln in body.splitlines() if "truncate_token=" in ln)
|
||||
token_str = token_line.split("truncate_token=", 1)[1].rstrip("]")
|
||||
token = decode_truncate_token(token_str)
|
||||
assert token is not None
|
||||
# The body is kept in memory, so resuming never repeats the request.
|
||||
assert token.kind == "memory"
|
||||
assert token.limit == 1000
|
||||
assert 0 < token.offset < 1000
|
||||
|
||||
|
||||
async def test_fetch_offset_skips_body(workspace: Path, http_server: str) -> None:
|
||||
del workspace
|
||||
port = int(http_server.rsplit(":", 1)[1])
|
||||
grant_private_host("127.0.0.1", port)
|
||||
|
||||
# /big body is 5000 chars; offset 4900 leaves 100 chars, no token needed.
|
||||
out = await call_async(fetch_local, f"{http_server}/big", max_chars=200, offset=4900)
|
||||
# /big body is 5000 chars; offset 4900 leaves the last 100 chars.
|
||||
out = await call_async(fetch_local, f"{http_server}/big", offset=4900)
|
||||
body = out.split("--- body ---", 1)[-1]
|
||||
assert body.strip() == "x" * 100
|
||||
assert "truncate_token=" not in out
|
||||
|
||||
|
||||
async def test_fetch_keeps_the_body_whole(workspace: Path, http_server: str) -> None:
|
||||
"""The body is stored whole (the request-prep pass caps it on the wire)."""
|
||||
del workspace
|
||||
port = int(http_server.rsplit(":", 1)[1])
|
||||
grant_private_host("127.0.0.1", port)
|
||||
|
||||
out = await call_async(fetch_local, f"{http_server}/big")
|
||||
body = out.split("--- body ---", 1)[-1].strip()
|
||||
assert body == "x" * 5000
|
||||
assert "truncate_token=" not in out
|
||||
|
||||
|
||||
async def test_fetch_private_denied_without_grant(workspace: Path, http_server: str) -> None:
|
||||
del workspace
|
||||
# No grant, no policy hook → hard deny (even though server is up).
|
||||
|
||||
@@ -24,8 +24,8 @@ class _Client:
|
||||
return self.page
|
||||
|
||||
|
||||
def _tool(client: _Client, *, max_chars: int = 32_000):
|
||||
return build_fetch_tool(client, default_max_chars=max_chars)
|
||||
def _tool(client: _Client):
|
||||
return build_fetch_tool(client)
|
||||
|
||||
|
||||
def test_definition_shape() -> None:
|
||||
@@ -33,7 +33,7 @@ def test_definition_shape() -> None:
|
||||
assert definition.name == FETCH_TOOL_NAME == "fetch"
|
||||
assert definition.tags == ToolTag.LOCAL | ToolTag.READ_ONLY
|
||||
properties = definition.parameters["properties"]
|
||||
assert set(properties) == {"url", "offset", "max_chars"}
|
||||
assert set(properties) == {"url", "offset"}
|
||||
assert definition.parameters["required"] == ["url"]
|
||||
assert "fetch_local" in definition.description
|
||||
|
||||
@@ -48,7 +48,6 @@ async def test_handler_renders_title_url_and_content() -> None:
|
||||
"title=Example article",
|
||||
"url=https://example.com/article",
|
||||
"chars=16",
|
||||
"truncated=false",
|
||||
"--- content ---",
|
||||
"# Heading",
|
||||
"",
|
||||
@@ -57,14 +56,13 @@ async def test_handler_renders_title_url_and_content() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_offset_and_truncation_keep_the_remainder_resumable() -> None:
|
||||
async def test_offset_skips_leading_content() -> None:
|
||||
client = _Client(page=FetchResponse(title="", content="0123456789"))
|
||||
result = await _tool(client, max_chars=3).handler(url="https://example.com/", offset=5)
|
||||
result = await _tool(client).handler(url="https://example.com/", offset=5)
|
||||
|
||||
assert "title=(untitled)" in result
|
||||
assert "--- content ---\n567" in result
|
||||
assert "truncated=true" in result
|
||||
assert "truncate_token=" in result
|
||||
assert "--- content ---\n56789" in result
|
||||
assert "truncate_token=" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -84,52 +84,16 @@ async def test_get_truncated_file_missing(workspace: Path) -> None:
|
||||
assert out.startswith("error:")
|
||||
|
||||
|
||||
async def test_get_truncated_serves_a_fetch_body_without_refetching(monkeypatch) -> None:
|
||||
"""A fetch body's token reads the stored remainder; the request is not repeated."""
|
||||
from plyngent.tools.net.fetch_local import format_fetch_result
|
||||
|
||||
fetch_module = importlib.import_module("plyngent.tools.net.fetch_local")
|
||||
|
||||
async def _must_not_refetch(*_args: object, **_kwargs: object) -> str:
|
||||
msg = "resuming a fetch body must not re-run the request"
|
||||
raise AssertionError(msg)
|
||||
|
||||
monkeypatch.setattr(fetch_module, "http_fetch", _must_not_refetch)
|
||||
first = format_fetch_result(
|
||||
status=200,
|
||||
final_url="https://example.com/x",
|
||||
content_type="text/plain",
|
||||
body_text="x" * 600,
|
||||
body_bytes=600,
|
||||
truncated=False,
|
||||
redirects=0,
|
||||
method="GET",
|
||||
security="public",
|
||||
warnings=[],
|
||||
body_kind="text",
|
||||
max_chars=200,
|
||||
)
|
||||
token = _extract_token(first)
|
||||
assert token is not None
|
||||
parsed = decode_truncate_token(token)
|
||||
assert parsed is not None
|
||||
assert parsed.kind == "memory"
|
||||
out = await call_async(get_truncated, token, max_chars=200)
|
||||
content, _, _ = out.partition("\n[Truncated")
|
||||
assert content == "x" * len(content)
|
||||
assert 0 < len(content) <= 200
|
||||
|
||||
|
||||
async def test_get_truncated_memory_chains() -> None:
|
||||
from plyngent.tools.truncate_token import truncate_generic
|
||||
from plyngent.tools.truncate_token import truncate_to_memory
|
||||
|
||||
bounded = truncate_generic("m" * 1000, 200)
|
||||
bounded, _token = truncate_to_memory("m" * 1000, 200)
|
||||
assert "truncate_token=" in bounded
|
||||
token = bounded.split("truncate_token=", 1)[1].rstrip("]")
|
||||
parsed = decode_truncate_token(token)
|
||||
assert parsed is not None
|
||||
assert parsed.kind == "memory"
|
||||
cursor = parsed.offset # chars already delivered by truncate_generic
|
||||
cursor = parsed.offset # chars already delivered by truncate_to_memory
|
||||
delivered = cursor
|
||||
for _ in range(20):
|
||||
out = await call_async(get_truncated, token, max_chars=200)
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.agent import ToolDefinition, ToolRegistry
|
||||
from plyngent.tools.danger import classify_danger
|
||||
from plyngent.tools.process import (
|
||||
ask_into_pty,
|
||||
close_pty,
|
||||
@@ -20,6 +24,9 @@ from plyngent.tools.process.pty_session import PtyManager
|
||||
from plyngent.tools.workspace import set_command_denylist
|
||||
from tests.test_tools.helpers import call_async, call_sync
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
def _session_id(opened: str) -> int:
|
||||
for line in opened.splitlines():
|
||||
@@ -112,33 +119,31 @@ async def test_run_argv_batch_stop_on_error(workspace: object) -> None:
|
||||
assert "should-not-run" not in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_pipe_out(workspace: object) -> None:
|
||||
async def test_run_argv_batch_pipe(workspace: object) -> None:
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[
|
||||
{
|
||||
"argv": _py("print('piped-payload', end='')"),
|
||||
"pipe_out": True,
|
||||
},
|
||||
{
|
||||
"argv": _py("import sys; print(sys.stdin.read())"),
|
||||
"pipe": {"argv": _py("import sys; print(sys.stdin.read())")},
|
||||
},
|
||||
],
|
||||
)
|
||||
assert "ran=2" in out
|
||||
assert "piped-payload" in out
|
||||
assert "pipe_out=true" in out
|
||||
assert "pipe=true" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_last_pipe_out_error(workspace: object) -> None:
|
||||
async def test_run_argv_batch_pipe_out_is_gone(workspace: object) -> None:
|
||||
"""The old provider-side flag is refused, pointing at the pipe node."""
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[{"argv": _py("print(1)"), "pipe_out": True}],
|
||||
)
|
||||
assert "error:" in out
|
||||
assert "pipe_out" in out
|
||||
assert '{"pipe": [...]}' in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_mix_stderr(workspace: object) -> None:
|
||||
@@ -149,9 +154,8 @@ async def test_run_argv_batch_mix_stderr(workspace: object) -> None:
|
||||
{
|
||||
"argv": _py("import sys; sys.stdout.write('out'); sys.stderr.write('err')"),
|
||||
"mix_stderr": True,
|
||||
"pipe_out": True,
|
||||
"pipe": {"argv": _py("import sys; print(repr(sys.stdin.read()))")},
|
||||
},
|
||||
{"argv": _py("import sys; print(repr(sys.stdin.read()))")},
|
||||
],
|
||||
)
|
||||
assert "ran=2" in out
|
||||
@@ -159,6 +163,238 @@ async def test_run_argv_batch_mix_stderr(workspace: object) -> None:
|
||||
assert "out" in out and "err" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_then_runs_on_success(workspace: object) -> None:
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[{"argv": _py("print('first')"), "then": {"argv": _py("print('then-body')")}}],
|
||||
)
|
||||
assert "then-body" in out
|
||||
assert "ran=2" in out
|
||||
assert "depth=1" in out
|
||||
assert "via=then of step 0" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_else_handles_the_failure(workspace: object) -> None:
|
||||
"""A failure with an ``else`` is answered, so the batch keeps going."""
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[
|
||||
{
|
||||
"argv": _py("import sys; sys.exit(2)"),
|
||||
"else": {"argv": _py("print('else-body')")},
|
||||
},
|
||||
{"argv": _py("print('sibling-ran')")},
|
||||
],
|
||||
)
|
||||
assert "else-body" in out
|
||||
assert "sibling-ran" in out
|
||||
assert "via=else of step 0" in out
|
||||
assert "stopped_early=false" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_runs_a_then_list_in_order(workspace: object) -> None:
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[
|
||||
{
|
||||
"argv": _py("print('first')"),
|
||||
"then": [{"argv": _py("print('second')")}, {"argv": _py("print('third')")}],
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "ran=3" in out
|
||||
assert out.index("first") < out.index("second") < out.index("third")
|
||||
|
||||
|
||||
async def test_run_argv_batch_a_failing_branch_stops_the_batch(workspace: object) -> None:
|
||||
"""Nobody answered a failure inside a continuation, so the batch stops."""
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[
|
||||
{"argv": _py("print('first')"), "then": {"argv": _py("import sys; sys.exit(4)")}},
|
||||
{"argv": _py("print('sibling-ran')")},
|
||||
],
|
||||
)
|
||||
assert "stopped_early=true" in out
|
||||
assert "sibling-ran" not in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_stop_on_error_false_skips_only_its_continuations(workspace: object) -> None:
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[
|
||||
{
|
||||
"argv": _py("import sys; sys.exit(2)"),
|
||||
"stop_on_error": False,
|
||||
"then": {"argv": _py("print('then-ran')")},
|
||||
},
|
||||
{"argv": _py("print('sibling-ran')")},
|
||||
],
|
||||
)
|
||||
assert "then-ran" not in out
|
||||
assert "sibling-ran" in out
|
||||
assert "stopped_early=false" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_branches_on_the_pipe_result(workspace: object) -> None:
|
||||
"""A piped node branches on the last command of its chain."""
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[
|
||||
{
|
||||
"argv": _py("print('payload', end='')"),
|
||||
"pipe": {"argv": _py("import sys; sys.stdin.read(); sys.exit(3)")},
|
||||
"then": {"argv": _py("print('pipe-ok')")},
|
||||
"else": {"argv": _py("print('pipe-failed')")},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "pipe-failed" in out
|
||||
assert "pipe-ok" not in out
|
||||
assert "ran=3" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_pipe_step_takes_no_branch(workspace: object) -> None:
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[{"argv": _py("print(1)"), "pipe": {"argv": _py("print(2)"), "then": {"argv": _py("print(3)")}}}],
|
||||
)
|
||||
assert "error:" in out
|
||||
assert "pipe step takes no then/else" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_pipe_step_takes_no_stdin(workspace: object) -> None:
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[{"argv": _py("print(1)"), "pipe": {"argv": _py("print(2)"), "stdin": "own"}}],
|
||||
)
|
||||
assert "error:" in out
|
||||
assert "stdin comes from the pipe" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_rejects_a_deep_tree(workspace: object) -> None:
|
||||
del workspace
|
||||
node: dict[str, object] = {"argv": _py("print(1)")}
|
||||
for _ in range(6):
|
||||
node = {"argv": _py("print(1)"), "then": node}
|
||||
out = await call_async(run_argv_batch, [node])
|
||||
assert "error:" in out
|
||||
assert "nest at most 4 levels deep" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_counts_nested_nodes(workspace: object) -> None:
|
||||
"""Every node of the tree costs one, so a nested tree hits the same cap."""
|
||||
del workspace
|
||||
out = await call_async(run_argv_batch, [{"argv": _py("print(1)")} for _ in range(21)])
|
||||
assert "error:" in out
|
||||
assert "more than 20 nodes" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_names_the_node_grammar(workspace: object) -> None:
|
||||
del workspace
|
||||
out = await call_async(run_argv_batch, [{"argv": _py("print(1)"), "when": "always"}])
|
||||
assert "error:" in out
|
||||
assert "unknown key(s) when" in out
|
||||
assert "then, else, pipe" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_a_continuation_runs_where_its_parent_ran(workspace: Path) -> None:
|
||||
"""``then`` inherits the parent's ``cwd`` and ``env`` unless it names its own."""
|
||||
(workspace / "sub").mkdir()
|
||||
(workspace / "own").mkdir()
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[
|
||||
{
|
||||
"argv": _py("import os; print('parent', os.path.basename(os.getcwd()), os.environ['FOO'])"),
|
||||
"cwd": "sub",
|
||||
"env": {"FOO": "inherited"},
|
||||
"then": [
|
||||
{"argv": _py("import os; print('then', os.path.basename(os.getcwd()), os.environ['FOO'])")},
|
||||
{
|
||||
"argv": _py("import os; print('own', os.path.basename(os.getcwd()), os.environ['FOO'])"),
|
||||
"cwd": "own",
|
||||
"env": {"FOO": "own"},
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "parent sub inherited" in out
|
||||
assert "then sub inherited" in out
|
||||
assert "own own own" in out
|
||||
|
||||
|
||||
async def test_run_argv_batch_a_pipe_runs_to_its_end(workspace: object) -> None:
|
||||
"""A failing pipe stage does not skip the rest; the last command decides."""
|
||||
del workspace
|
||||
out = await call_async(
|
||||
run_argv_batch,
|
||||
[
|
||||
{
|
||||
"argv": _py("import sys; sys.exit(3)"),
|
||||
"pipe": {"argv": _py("import sys; print('second-stage-ran'); sys.exit(0)")},
|
||||
"else": {"argv": _py("print('branch-on-pipe-end')")},
|
||||
},
|
||||
{"argv": _py("print('batch-continues')")},
|
||||
],
|
||||
)
|
||||
assert "second-stage-ran" in out
|
||||
assert "branch-on-pipe-end" not in out # the chain's last exit code was 0
|
||||
assert "batch-continues" in out
|
||||
|
||||
|
||||
def _asking_registry(*definitions: ToolDefinition) -> tuple[ToolRegistry, list[str]]:
|
||||
"""A registry whose confirm hook records the calls it was asked about."""
|
||||
asked: list[str] = []
|
||||
|
||||
def on_confirm(name: str, args: Mapping[str, object], reason: str) -> bool:
|
||||
del args, reason
|
||||
asked.append(name)
|
||||
return True
|
||||
|
||||
return ToolRegistry(list(definitions), danger=classify_danger, on_confirm=on_confirm), asked
|
||||
|
||||
|
||||
async def test_a_bad_timeout_is_answered_before_the_prompt(workspace: object) -> None:
|
||||
"""A call that cannot run is answered, never put in front of the human."""
|
||||
del workspace
|
||||
registry, asked = _asking_registry(run_argv)
|
||||
out = await registry.execute(
|
||||
"run_argv",
|
||||
json.dumps({"argv": ["bash", "-c", "echo hi"], "timeout_seconds": "soon"}),
|
||||
)
|
||||
assert asked == []
|
||||
assert "timeout_seconds" in out
|
||||
assert "error:" in out
|
||||
|
||||
|
||||
async def test_a_broken_batch_is_answered_before_the_prompt(workspace: object) -> None:
|
||||
del workspace
|
||||
registry, asked = _asking_registry(run_argv_batch)
|
||||
out = await registry.execute(
|
||||
"run_argv_batch",
|
||||
json.dumps(
|
||||
{
|
||||
"steps": [
|
||||
{"argv": ["bash", "-c", "echo hi"]},
|
||||
{"argv": ["echo", "x"], "pipe": {"argv": ["echo", "y"], "stdin": "own"}},
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
assert asked == []
|
||||
assert "stdin comes from the pipe" in out
|
||||
|
||||
|
||||
async def test_run_argv_denied(workspace: object) -> None:
|
||||
del workspace
|
||||
out = await call_async(run_argv, ["rm", "-rf", "."])
|
||||
@@ -192,14 +428,14 @@ async def test_run_argv_timeout_keeps_partial_output(workspace: object) -> None:
|
||||
assert "partial-out" in out
|
||||
|
||||
|
||||
async def test_run_argv_stdout_cap_embeds_token(workspace: object) -> None:
|
||||
async def test_run_argv_returns_the_full_stdout(workspace: object) -> None:
|
||||
del workspace
|
||||
# Per-stream cap default is 32000 chars: a bigger stdout embeds a token.
|
||||
# The result is stored whole; the request-prep pass caps it on the wire and
|
||||
# names its document, so nothing is cut at the tool.
|
||||
out = await call_async(run_argv, _py("print('x' * 40000)"))
|
||||
assert "truncate_token=" in out
|
||||
token_line = next(ln for ln in out.splitlines() if "truncate_token=" in ln)
|
||||
assert "[Truncated (32000 chars max.;" in token_line
|
||||
assert "[Truncated" in out
|
||||
body = out.split("--- stdout ---", 1)[1].split("--- stderr ---", 1)[0].strip()
|
||||
assert body == "x" * 40000
|
||||
assert "truncate_token=" not in out
|
||||
|
||||
|
||||
async def test_run_argv_small_stdout_no_marker(workspace: object) -> None:
|
||||
|
||||
@@ -309,10 +309,26 @@ async def test_skill_write_prompt_denial_is_reported(tmp_path: Path) -> None:
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(confirm=_deny, timeout=3.0)):
|
||||
out = await skill_edit.handler("acme", old_string="Before.", new_string="After.")
|
||||
assert out.startswith("error: skill write to ")
|
||||
assert out.endswith("denied (declined or timed out after 3s)")
|
||||
assert out.endswith("denied (user declined)")
|
||||
assert "After." not in (root / "acme" / SKILL_FILE).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
async def test_a_skill_write_timeout_names_the_deadline(tmp_path: Path) -> None:
|
||||
"""Nobody answering is not the same message as a human saying no."""
|
||||
from plyngent.agent import Denial, DenialKind
|
||||
|
||||
root = _write_root(tmp_path)
|
||||
_ = _write_skill(root, "acme", body="Before.")
|
||||
|
||||
def silent(*_args: object) -> SkillWriteDecision:
|
||||
return SkillWriteDecision(allow=False, denial=Denial(DenialKind.TIMEOUT, seconds=3.0))
|
||||
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(confirm=silent, timeout=3.0)):
|
||||
out = await skill_edit.handler("acme", old_string="Before.", new_string="After.")
|
||||
assert out.endswith("denied (no answer within 3s — denied by default)")
|
||||
assert "user" not in out
|
||||
|
||||
|
||||
async def test_skill_write_confirm_sees_the_action_and_the_skill(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
seen: list[tuple[Path, str, str, float]] = []
|
||||
@@ -344,6 +360,30 @@ async def test_skill_write_session_grant_is_asked_once(tmp_path: Path) -> None:
|
||||
assert (root / "acme" / "notes.md").read_text(encoding="utf-8") == "note\n"
|
||||
|
||||
|
||||
async def test_a_skill_write_says_which_answer_let_it_through(tmp_path: Path) -> None:
|
||||
"""Once, this chat, and the standing config each read differently."""
|
||||
from plyngent.agent import Approval
|
||||
from plyngent.tools.context import collect_approvals
|
||||
|
||||
root = _write_root(tmp_path)
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(confirm=lambda *_args: True)), collect_approvals() as once:
|
||||
created = await skill_create.handler("once-skill", "d", "b")
|
||||
|
||||
def for_the_chat(*_args: object) -> SkillWriteDecision:
|
||||
return SkillWriteDecision(allow=True, session=True)
|
||||
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(confirm=for_the_chat)), collect_approvals() as chat:
|
||||
_ = await skill_create.handler("chat-skill", "d", "b")
|
||||
_ = await skill_edit.handler("chat-skill", append="\nMore.\n")
|
||||
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)), collect_approvals() as config:
|
||||
_ = await skill_create.handler("config-skill", "d", "b")
|
||||
|
||||
assert once == [Approval("user", "once")], created
|
||||
assert chat == [Approval("user", "session"), Approval("grant", "session")]
|
||||
assert config == [Approval("config", "policy")]
|
||||
|
||||
|
||||
async def test_skill_write_denylist_wins_over_a_grant(tmp_path: Path) -> None:
|
||||
root = _write_root(tmp_path)
|
||||
with _bound(tmp_path, root, policy=SkillWritePolicy(allow_all=True)):
|
||||
|
||||
@@ -5,7 +5,7 @@ from plyngent.tools.truncate_token import (
|
||||
decode_truncate_token,
|
||||
encode_truncate_token,
|
||||
get_remainder,
|
||||
truncate_generic,
|
||||
truncate_to_memory,
|
||||
truncate_with_token,
|
||||
truncation_marker,
|
||||
)
|
||||
@@ -125,26 +125,26 @@ def test_truncate_with_token_nonzero_start() -> None:
|
||||
assert len(out) <= 200
|
||||
|
||||
|
||||
def test_truncate_generic_short_untouched() -> None:
|
||||
assert truncate_generic("tiny", 200) == "tiny"
|
||||
def test_truncate_to_memory_short_untouched() -> None:
|
||||
out, token = truncate_to_memory("tiny", 200)
|
||||
assert out == "tiny"
|
||||
assert token is None
|
||||
|
||||
|
||||
def test_truncate_generic_embeds_memory_token() -> None:
|
||||
def test_truncate_to_memory_embeds_memory_token() -> None:
|
||||
text = "a" * 500
|
||||
out = truncate_generic(text, 200)
|
||||
out, token = truncate_to_memory(text, 200)
|
||||
assert token is not None
|
||||
assert len(out) <= 200
|
||||
assert "[Truncated (200 chars max." in out
|
||||
token_str = out.split("truncate_token=", 1)[1].rstrip("]")
|
||||
token = decode_truncate_token(token_str)
|
||||
assert token is not None
|
||||
assert token.kind == "memory"
|
||||
# Remainder is the full original text; token offset points into it.
|
||||
remainder = get_remainder(token.location)
|
||||
assert remainder == text
|
||||
assert get_remainder(token.location) == text
|
||||
assert token.offset < len(text)
|
||||
|
||||
|
||||
def test_truncate_generic_no_marker_when_fits() -> None:
|
||||
out = truncate_generic("a" * 50, 200)
|
||||
def test_truncate_to_memory_no_marker_when_fits() -> None:
|
||||
out, token = truncate_to_memory("a" * 50, 200)
|
||||
assert out == "a" * 50
|
||||
assert "Truncated" not in out
|
||||
assert token is None
|
||||
|
||||
@@ -50,6 +50,21 @@ def test_escape_error_names_the_mode_to_request(workspace: object, required: Acc
|
||||
_ = resolve_path("../outside", required=required)
|
||||
|
||||
|
||||
def test_escape_error_offers_a_temporary_workspace_for_a_write(workspace: object) -> None:
|
||||
"""A refused write is often scratch, which never needs a grant."""
|
||||
del workspace
|
||||
with pytest.raises(WorkspaceError, match="new_temporary_workspace"):
|
||||
_ = resolve_path("../scratch.txt", required=AccessMode.WRITE)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("required", [AccessMode.READ, AccessMode.EXEC])
|
||||
def test_escape_error_hides_the_scratch_hint_from_other_modes(workspace: object, required: AccessMode) -> None:
|
||||
del workspace
|
||||
with pytest.raises(WorkspaceError) as refusal:
|
||||
_ = resolve_path("../scratch.txt", required=required)
|
||||
assert "new_temporary_workspace" not in str(refusal.value)
|
||||
|
||||
|
||||
def test_access_mode_parse_and_covers() -> None:
|
||||
assert parse_access_mode(" Read ") is AccessMode.READ
|
||||
assert parse_access_mode("EXEC") is AccessMode.EXEC
|
||||
@@ -220,6 +235,41 @@ def test_command_denylist_policy_confirm_allow(workspace: object) -> None:
|
||||
clear_policy_allowed_commands()
|
||||
|
||||
|
||||
def test_a_denylist_answer_is_recorded_as_a_user_process_approval(workspace: object) -> None:
|
||||
"""The answer that let the call through is what the result has to say."""
|
||||
del workspace
|
||||
from plyngent.agent import Approval
|
||||
from plyngent.tools.context import collect_approvals
|
||||
from plyngent.tools.workspace import clear_policy_allowed_commands, set_policy_confirm_hook
|
||||
|
||||
set_policy_confirm_hook(lambda *_args: True)
|
||||
clear_policy_allowed_commands()
|
||||
try:
|
||||
with collect_approvals() as recorded:
|
||||
check_command_allowed(["sudo", "id"])
|
||||
finally:
|
||||
set_policy_confirm_hook(None)
|
||||
clear_policy_allowed_commands()
|
||||
assert recorded == [Approval("user", "process")]
|
||||
|
||||
|
||||
def test_a_matching_denylist_grant_is_recorded_as_a_process_grant(workspace: object) -> None:
|
||||
"""Nobody is asked the second time, and the result says so."""
|
||||
del workspace
|
||||
from plyngent.agent import Approval
|
||||
from plyngent.tools.context import collect_approvals
|
||||
from plyngent.tools.workspace import clear_policy_allowed_commands, grant_policy_command
|
||||
|
||||
clear_policy_allowed_commands()
|
||||
grant_policy_command("sudo")
|
||||
try:
|
||||
with collect_approvals() as recorded:
|
||||
check_command_allowed(["sudo", "id"])
|
||||
finally:
|
||||
clear_policy_allowed_commands()
|
||||
assert recorded == [Approval("grant", "process")]
|
||||
|
||||
|
||||
def test_command_denylist_policy_confirm_deny(workspace: object) -> None:
|
||||
del workspace
|
||||
from plyngent.tools.workspace import (
|
||||
@@ -230,13 +280,30 @@ def test_command_denylist_policy_confirm_deny(workspace: object) -> None:
|
||||
set_policy_confirm_hook(lambda *_a: False)
|
||||
clear_policy_allowed_commands()
|
||||
try:
|
||||
with pytest.raises(WorkspaceError, match="declined or timed out"):
|
||||
with pytest.raises(WorkspaceError, match="user declined"):
|
||||
check_command_allowed(["sudo", "echo", "x"])
|
||||
finally:
|
||||
set_policy_confirm_hook(None)
|
||||
clear_policy_allowed_commands()
|
||||
|
||||
|
||||
def test_command_denylist_policy_timeout_names_the_deadline(workspace: object) -> None:
|
||||
"""Silence is reported as the clock, never as the human's decision."""
|
||||
del workspace
|
||||
from plyngent.agent import Denial, DenialKind
|
||||
from plyngent.tools.workspace import clear_policy_allowed_commands, set_policy_confirm_hook
|
||||
|
||||
set_policy_confirm_hook(lambda *_a: Denial(DenialKind.TIMEOUT, seconds=3.0))
|
||||
clear_policy_allowed_commands()
|
||||
try:
|
||||
with pytest.raises(WorkspaceError, match="no answer within 3s") as refusal:
|
||||
check_command_allowed(["sudo", "echo", "x"])
|
||||
finally:
|
||||
set_policy_confirm_hook(None)
|
||||
clear_policy_allowed_commands()
|
||||
assert "declined" not in str(refusal.value)
|
||||
|
||||
|
||||
def test_command_denylist_policy_confirm_timeout_value(workspace: object) -> None:
|
||||
del workspace
|
||||
from plyngent.tools.workspace import (
|
||||
|
||||
Reference in New Issue
Block a user