mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -86,32 +86,32 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
||||
- **Images** (see `doc/images.md`): a user turn's `content` may be a part array (`ChatTextPart` + `ChatImagePart`), and a tool result carries its pictures in `ToolChatMessage.images`; each surface lifts them into what it can express — chat-completions content blocks (tool-result images move into the user turn after the tool run, since a `tool` message holds text only), Anthropic `image` blocks (inside `tool_result` for a tool result), Responses `input_image` parts (in a `function_call_output` item). Pictures belong to user turns: an image in a system or assistant message is a provider 400. `agent/images.py` sniffs the format from the bytes and caps an inline picture at 8 MiB, because every round re-sends it; `[providers.*.models.*].image_in` is the opt-in, and every image-carrying path checks it — an `@path` mention, `view_image`, `fetch_local`'s image body, and (as a safety net in the registry) an MCP server's or plugin's `ToolOutput`.
|
||||
- **Provider-side tools**: `provider_tools` on the OpenAI **and** DeepSeek presets (list of dicts; `[{type="web_search"}]` when omitted; `[]` disables) threaded via `ChatAgent`/`run_chat_loop` into Responses and Messages; never executed by `ToolRegistry`. The Messages surface declares its own server tool instead — a bare `web_search` becomes `{type="web_search_20250305", name="web_search"}` (the search then runs inside the response, and its `encrypted_content` results go back verbatim with the round — `AssistantChatMessage.provider_blocks`) — and a DeepSeek provider on a surface that ignores hosted tools (`config.routing.local_web_search`) gets the local `web_search` tool from `tools/net/search.py` instead.
|
||||
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name. A handler returns its text, or a `ToolOutput(text, images)` when it also produced a picture: `execute` keeps the text-only contract and `execute_output` returns the pair, which `run_chat_loop` stores as the tool message's text plus its `images` (`view_image`, an image body from `fetch_local`, and an MCP `image` content block all take that path). Before running, `execute` answers a call that omits a required argument with the accepted names (`_missing_required_error`) — the handler's bare `TypeError` never reaches the model, and no confirm prompt fires for a call that cannot run. Shape mistakes inside a *present* argument are the tool's own job (see the `chat` bullet). `@tool(writes=("path",))` names a handler's file arguments: one batch applies calls that name the same file one at a time, in call order — the lock is taken before the confirm prompt, so a pending prompt cannot be overtaken — while different files keep running in parallel.
|
||||
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`; resolved `reasoning_effort` on every request (see Config). A round the provider cut off at the output limit is kept, not failed: the answer and its thinking blocks stay in `messages`, the round's tool calls are dropped (a half-written call must not run — the model re-issues it), and up to `max_truncation_continues` (2) further rounds carry on from a truncation notice; 0 spends the turn as unfinished instead, for `/retry`.
|
||||
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; request-prep tool-result cap (each result stored whole, trimmed on the request copy with a `document_id=...` marker); soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`; resolved `reasoning_effort` on every request (see Config). A round the provider cut off at the output limit is kept, not failed: the answer and its thinking blocks stay in `messages`, the round's tool calls are dropped (a half-written call must not run — the model re-issues it), and up to `max_truncation_continues` (2) further rounds carry on from a truncation notice; 0 spends the turn as unfinished instead, for `/retry`.
|
||||
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `provider_tools`; `retry()` continues incomplete turns (user-only, after committed tools — does not re-run those tools — or after a kept truncated answer) and `pending_retry_text` reads a truncation notice as unfinished work.
|
||||
- **`/compact`**: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first. The default summarizer system prompt states that tool calls are unavailable (a `compact_system_prompt` override replaces that default wholesale).
|
||||
- Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **truncated** (`kept_chars`/`continued`), **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
|
||||
- Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`.
|
||||
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `allow_paths` (static out-of-workspace pre-allow `path → read|write|exec`; never prompts), `auto_continue_limits` (auto-raise tool/PTY limits without prompting; default false), `reasoning_effort` / `thinking_budget_tokens` (thinking strength; see Config), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
|
||||
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `allow_paths` (static out-of-workspace pre-allow `path → read|write|exec`; never prompts), `auto_continue_limits` (auto-raise tool/PTY limits without prompting; default false), `reasoning_effort` / `thinking_budget_tokens` (thinking strength; see Config), `max_tool_result_chars` (wire cap; the request-prep pass trims a longer tool result to its head plus a `document_id=...` marker), `hard_max_tool_result_chars` (default 2,000,000: storage ceiling for one result kept whole in its message; a result above it keeps its head behind a leading `[Stored at the …-char ceiling: …]` notice and its document cannot serve the dropped tail), `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens).
|
||||
|
||||
### Tools (`tools/`)
|
||||
|
||||
Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
||||
|
||||
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots **or** a directory-access grant (mode-aware: `read` < `write` < `exec`, default required `write`; a file grant matches only that file); path substring denylist (wins over grants); command denylist via `match_command_denylist` (checks every program of the wrapper/`run` chain, resolved by `tools/command_scan.py`; an entry is a program, plus optional argument words that make it dangerous — `systemctl reboot poweroff …` matches `systemctl reboot` and `systemctl start reboot.target`, not `systemctl status`; basenames are lower-cased, so `RM` / `rm.exe` count; defaults cover privilege `sudo`/`su`/`doas`/`pkexec`/`run0`/`systemd-run`, destructive `rm`/`mkfs`/`dd`/`chown`/`mount`/…, and power state `shutdown`/`reboot`/`poweroff`/`halt`/`init`/`telinit`/`kexec`/`pm-*`/`s2*`; CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); escape error names the `request_directory_access` mode the call needs (`call request_directory_access with mode='write'`).
|
||||
- **`access`** (`tools/access.py`): `request_directory_access` (LOCAL|INSTANCE|SESSION) asks the host hook for out-of-workspace access; grants are one `path → mode` pair in one of three stores — `WorkspacePolicy.config_allow` (static `[agent].allow_paths`), `SessionState.access_grants` (human-approved, persisted on the session row), `WorkspacePolicy.yolo_allow` (`--yes`/`/yolo`, process only). `AccessDecision(mode, persist)` from the hook; denylisted paths can never be granted; cap `MAX_ACCESS_GRANTS`. Not a sandbox: `exec` grants (and any command) reach the whole FS.
|
||||
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range; an image file is refused with a pointer to `view_image`), `view_image` (a picture reaches the model as an image part; format sniffed from the bytes, `detail` = `auto` default / `low` / `high` / `original`), `write_file` (refuses to replace an existing file unless `overwrite=true`; that replacement is the one soft-confirmed), `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `regex_files` (Python `re` per line, not a glob; literal file/dir `path`, no glob expansion; skips VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path` (moves the path into the system trash — freedesktop trash / Finder trash / Recycle Bin — so a delete can be undone; `permanent=true`, `[trash] enabled = false`, or a path under the OS temp dir delete it for good, and a trashing that fails reports the reason and leaves the path alone; [doc/trash.md](doc/trash.md)), `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume any truncated result via its `truncate_token=...`).
|
||||
- **`access`** (`tools/access.py`): `request_directory_access` (LOCAL|INSTANCE|SESSION) asks the host hook for out-of-workspace access; grants are one `path → mode` pair in one of three stores — `WorkspacePolicy.config_allow` (static `[agent].allow_paths`), `SessionState.access_grants` (human-approved, persisted on the session row), `WorkspacePolicy.yolo_allow` (process only: an answer a host keeps without persisting — YOLO/`--yes` never fills it, an out-of-workspace grant is asked either way). `AccessDecision(mode, persist)` from the hook; denylisted paths can never be granted; cap `MAX_ACCESS_GRANTS`. Not a sandbox: `exec` grants (and any command) reach the whole FS.
|
||||
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range; an image file is refused with a pointer to `view_image`), `view_image` (a picture reaches the model as an image part; format sniffed from the bytes, `detail` = `auto` default / `low` / `high` / `original`), `write_file` (refuses to replace an existing file unless `overwrite=true`; that replacement is the one soft-confirmed), `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `regex_files` (Python `re` per line, not a glob; literal file/dir `path`, no glob expansion; skips VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path` (moves the path into the system trash — freedesktop trash / Finder trash / Recycle Bin — so a delete can be undone; `permanent=true`, `[trash] enabled = false`, or a path under the OS temp dir delete it for good, and a trashing that fails reports the reason and leaves the path alone; [doc/trash.md](doc/trash.md)), `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume a truncated `read_file` slice or `@path` mention via its `truncate_token=...`), `read_document` / `search_document` (read a capped tool result by its `document_id`: a line window with optional line numbers, or a per-line regex), `document_info` / `list_documents` (describe or list the session's documents).
|
||||
- **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_batch` (serial steps, `pipe_out` on provider, `mix_stderr`, `stop_on_error`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (key markers) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
|
||||
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`<c-c>`, `<esc>`, `<c-up>`, `<c-s-f5>`, `<up*3>`, `<m-x>`, `\xHH` — xterm modifier sequences where they exist; an unknown `<…>` is an error, never typed); secrets via `ask_into_pty` (not `write_pty` data); session limit/idle TTL/output budget; close terminate→kill.
|
||||
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget. The prompt accepts `y`/empty (once), `n`, or `yyy` = stop asking for the rest of the turn (`cli.limits` module flag, reset by `run_turn_with_retries`, `/btw`, session switches, and chat exit). `--auto-continue` / `[agent] auto_continue_limits` set the process-wide default (covers one-shot/PTY); the flag does not imply YOLO and vice versa.
|
||||
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for interpreters — bare shells/REPLs, scripts, and `-c` one-liners alike (`-c` is code only for an interpreter, so `grep -c` / `od -c` stay unflagged; argv + code preview) —, detached runners (`nohup`), and wrapper options that carry a command line (`env -S "…"`, `su -c "…"`, whose text is printed under its own `env -S:` label), each resolved past env wrappers and `<launcher> run` — every wrapper's options are spelled as a `getopt` spec (`command_scan._WRAPPERS`: flags as well as value options, so no option swallows the program), and an option the spec does not know stops the unwrap and is reviewed (`options not understood … the wrapped program is a guess`) while the wrapper itself stays the command the denylist judges; deny may include a user comment for the model. `ssh` / `mosh` are unwrapped like a wrapper (`command_scan`): the program reviewed is the one the client runs on the far end (`ssh prod rm -rf /` is refused as `rm`, `ssh prod bash -c …` prints the remote code, an escalation behind the hop keeps `yolo_skip=False`), and `-o BatchMode=yes` always asks because nothing on either end can prompt. A client with **no** command only opens a remote shell: nothing typed or piped into it (`write_pty`, `run_argv(stdin=…)`) is checked, so opening one is announced and confirmed instead — `remote shell on 'prod' (ssh with no command; its input is not checked)` — while the client itself stays outside the denylist (there is no program to name). The `delete_path` confirm names what will happen — `move path 'x' to the system trash`, or `delete path 'x' permanently` when the call asks for it, the host cleared `[trash]`, or the path is scratch — and the pretty line prints `(to trash)` for a delete that landed there. Session YOLO: `/yolo on|off|once` and `--yes` (skip the soft confirms of `YOLO`-tagged tools — interpreter runs included — while untagged ones still prompt and a command that escalates privileges keeps its confirm through `DangerReason(yolo_skip=False)`; hard denylists unchanged; `once` expires after the next user turn). A round of tool calls asks **once**: `ToolRegistry.prepare_batch_confirm` pre-scans it (unknown tools, undecodable arguments, calls missing a required argument, and calls YOLO already covers are left out — nobody is asked about a call that cannot run or will not ask), two or more soft reasons collapse into one `ToolBatchConfirmHook` prompt whose `BatchConfirmDecision` `execute(..., decision=…)` applies to each call (CLI box lists every call: `y` = allow these, `m` = review them one at a time through the per-call prompt, `N` = deny with one comment shared by all), the answer covers that round only (the next one asks again), and a lone request keeps the plain one-call prompt.
|
||||
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for interpreters — bare shells/REPLs, scripts, and `-c` one-liners alike (`-c` is code only for an interpreter, so `grep -c` / `od -c` stay unflagged; argv + code preview) —, detached runners (`nohup`), and wrapper options that carry a command line (`env -S "…"`, `su -c "…"`, whose text is printed under its own `env -S:` label), each resolved past env wrappers and `<launcher> run` — every wrapper's options are spelled as a `getopt` spec (`command_scan._WRAPPERS`: flags as well as value options, so no option swallows the program), and an option the spec does not know stops the unwrap and is reviewed (`options not understood … the wrapped program is a guess`) while the wrapper itself stays the command the denylist judges; deny may include a user comment for the model. `ssh` / `mosh` are unwrapped like a wrapper (`command_scan`): the program reviewed is the one the client runs on the far end (`ssh prod rm -rf /` is refused as `rm`, `ssh prod bash -c …` prints the remote code, an escalation behind the hop keeps `yolo_skip=False`), and `-o BatchMode=yes` always asks because nothing on either end can prompt. A client with **no** command only opens a remote shell: nothing typed or piped into it (`write_pty`, `run_argv(stdin=…)`) is checked, so opening one is announced and confirmed instead — `remote shell on 'prod' (ssh with no command; its input is not checked)` — while the client itself stays outside the denylist (there is no program to name). The `delete_path` confirm names what will happen — `move path 'x' to the system trash`, or `delete path 'x' permanently` when the call asks for it, the host cleared `[trash]`, or the path is scratch — and the pretty line prints `(to trash)` for a delete that landed there. Session YOLO: `/yolo on|off|once` and `--yes` (skip the soft confirms of `YOLO`-tagged tools — interpreter runs included — while untagged ones still prompt and a command that escalates privileges keeps its confirm through `DangerReason(yolo_skip=False)`; hard denylists unchanged and a directory-access request is asked either way; `once` expires after the next user turn). A round of tool calls asks **once**: `ToolRegistry.prepare_batch_confirm` pre-scans it (unknown tools, undecodable arguments, calls missing a required argument, and calls YOLO already covers are left out — nobody is asked about a call that cannot run or will not ask), two or more soft reasons collapse into one `ToolBatchConfirmHook` prompt whose `BatchConfirmDecision` `execute(..., decision=…)` applies to each call (CLI box lists every call: `y` = allow these, `m` = review them one at a time through the per-call prompt, `N` = deny with one comment shared by all), the answer covers that round only (the next one asks again), and a lone request keeps the plain one-call prompt. Every allow says so in the result itself: the tool text leads with `[approval] user (once)`, `user (round)` (batch), `grant (session)` (a TRUSTABLE grant, so nobody was asked this time) or `yolo (auto)`, and a handler that prompts on its own records it with `tools.context.record_approval` — `workspace.check_command_allowed` (`user`/`grant (process)`), `tools/net/grants.py` (`user`/`grant (process)`) and the skill write path (`user (once|session)`, `grant (session)`, `config (policy)`). One line per call (`Approval` + `most_specific_approval`: the answer closest to a human wins), none for a call that needed no confirmation or ended in `error:`, and it leads so a wire-capped result and `read_document` keep it. A refusal names which no it was (`Denial` / `DenialKind`): `user declined` (with the comment), `no answer within 30s — denied by default` (the prompt's deadline), `prompt cancelled` (Ctrl+C / EOF), or `no confirm hook or non-interactive (denied)` — a timeout is never reported as a decision, and every timed prompt (`workspace.check_command_allowed`, `tools/net/grants.py`, `tools/skills.py`, `tools/access.py`) carries the reason it got into the tool message.
|
||||
- **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems.
|
||||
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core); `wait` (line prompt with timeout; Enter disturbs). Container args are shape-checked (`tools/chat/shape.py`): a mis-typed string `options`/`fields` (or a non-string `question`) returns an explained error — `options="a, b"` used to iterate characters into a per-character menu. `wait`'s `duration` accepts numeric strings (`"5"` = 5s) and rejects booleans/text/containers.
|
||||
- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by the number a listing shows (render order, TOP group first; the stored `id` stays internal); DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work (`[TODO OPEN WORK]`); all-terminal non-empty = hygiene (`[TODO HYGIENE]`); nag channel via `[agent] todo_nag_strategy` (`developer`/`user` = prose nag; `synthetic_tool` = forged `todo_list` call + **real** `stack.render()` result + ToolCall/Result events; `none`).
|
||||
- **`net` / `fetch_local` and `fetch`**: `fetch_local` is the raw HTTP client (GET/POST/PUT/DELETE) via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (`asyncio` `getaddrinfo`). Private/loopback hosts need instance policy grant (CLI timed confirm; **not** YOLO). Public cleartext HTTP and mutating methods soft-confirm (`YOLO`/`TRUSTABLE`). HTTPS→HTTP redirects blocked unless `allow_http_downgrade`. `user_agent` tool arg (or headers) is never replaced by the default when provided. Caps: body bytes/chars, request body size. `fetch` is the remote read-only counterpart, built by the CLI host only for a DeepSeek provider: `POST /v1/tools/fetch` (`lmproto/deepseek/webtools/`) returns a title and Markdown content, billed per successful request (5 QPS, plus a per-domain limit). It does **not** run the local SSRF check — the request leaves from DeepSeek's network, so local/LAN targets are unreachable there (the endpoint answers 403/404) — and never falls back to `fetch_local` on its own. Its body remainder also lives in the memory store, so `get_truncated` resumes without another charge. Both are `READ_ONLY`; `fetch_local` enforces GET-only in a read-only context.
|
||||
- **`net` / `fetch_local` and `fetch`**: `fetch_local` is the raw HTTP client (GET/POST/PUT/DELETE) via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (`asyncio` `getaddrinfo`). Private/loopback hosts need instance policy grant (CLI timed confirm; **not** YOLO). Public cleartext HTTP and mutating methods soft-confirm (`YOLO`/`TRUSTABLE`). HTTPS→HTTP redirects blocked unless `allow_http_downgrade`. `user_agent` tool arg (or headers) is never replaced by the default when provided. Caps: body bytes, request body size. `fetch` is the remote read-only counterpart, built by the CLI host only for a DeepSeek provider: `POST /v1/tools/fetch` (`lmproto/deepseek/webtools/`) returns a title and Markdown content, billed per successful request (5 QPS, plus a per-domain limit). It does **not** run the local SSRF check — the request leaves from DeepSeek's network, so local/LAN targets are unreachable there (the endpoint answers 403/404) — and never falls back to `fetch_local` on its own. Its body is stored whole in the tool message, so a trimmed `document_id` reads the rest with no new request and no new charge. Both are `READ_ONLY`; `fetch_local` enforces GET-only in a read-only context.
|
||||
- **`net` / `web_search`** (`tools/net/search.py`, `tools/net/bing.py`): the search a DeepSeek provider cannot host, as a local tool. `build_web_search_tool(backends)` closes over the sources named by `provider.search_sources` and asks them **in fallback order** (`search_in_order`): `DeepseekSearchBackend` (one `POST /v1/tools/search` through `lmproto/deepseek/webtools/`, no model round — `deepseek_api_base_url` drops the convention's `/anthropic` and any `/v1` before the client appends its path; 408/429/5xx are retried 0.5s/1.0s, 402 and other 4xx are not) and/or `BingSearchBackend` (Bing's RSS results, snippets included, fetched through the same client as `fetch_local`; opt in with `search_sources = ["deepseek", "bing"]` — the default is `["deepseek"]`). Hits are `SearchHit` from `lmproto/search.py` (DeepSeek's `snippet`, else its `summary` cut to 200 chars); the handler returns one `title — url` entry per hit (plus the snippet when the source has one), and a source that raises or finds nothing is skipped and reported only if every source fails. Registered by the CLI host only when `config.routing.local_web_search` says so: a DeepSeek provider with a non-empty `search_sources`, unless the Anthropic convention already hosts the search (`convention = "anthropic"` **and** `provider_tools` ask for `web_search`) — so `provider_tools = []` brings the local tool back on that surface too. `provider_tools` owns the hosted tool, `search_sources` owns this one. Tags LOCAL|READ_ONLY.
|
||||
- **`mcp`** (`tools/mcp.py`): register one server's advertised tools into the catalog as namespaced `mcp__<server>__<tool>` definitions with `ToolSource(kind="mcp", plugin_id=server)`; tags LOCAL (+ READ_ONLY when the server config sets `read_only`) — never YOLO/TRUSTABLE. `register_mcp_tools` purges prior MCP entries then re-registers every connected server (hosts call it on tool-registry rebuild); connections must already be started by `McpManager`.
|
||||
- **`skills`** (`tools/skills.py`): discovery, reading, and writing of skill directories (`plyngent.skills` owns roots, the `SKILL.md` frontmatter subset, the uncached store, and the inside-the-skill path rule). `skill_list` (names, source root, directory, description, shadowed copies, frontmatter notes), `skill_read` (a `SKILL.md` by name, or one bundled file — reuses `read_file` for ranges/line numbers/`truncate_token`), `skill_search` (regex over every visible skill via `regex_files`, hits rebased on `<skill>/<file>`), `skill_create` (new `SKILL.md` + optional `files`), `skill_edit` (replace / `append` / rewrite one file; a rewritten `SKILL.md` is re-parsed and notes a lost description). Reads need no grant (the host publishes the roots as `WorkspacePolicy.static_read`, so `read_file`/`regex_files` see them while writes stay denied); writes go through **`SkillWritePolicy`** on the instance — denylist first, then `SessionState.skill_write_roots` (a `session` answer), then `[skills].allow_write`, then the host's confirm hook (CLI `prompt_skill_write_confirm`: `o`nce/`s`ession/`n`o, timed, default deny, **not** skipped by YOLO); a host with no hook denies. Grants are per **skill directory**; `[skills].enabled = false` drops the tools from the registry (`SKILL_TOOL_NAMES`).
|
||||
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + skills + net (`fetch_local`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `view_image`, `get_truncated`, `listdir`, `glob_paths`, `regex_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `skill_list`/`skill_read`/`skill_search`, `fetch`, `fetch_local`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch_local` accept GET only.
|
||||
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + skills + net (`fetch_local`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `view_image`, `get_truncated`, `read_document`/`search_document`/`document_info`/`list_documents`, `listdir`, `glob_paths`, `regex_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `skill_list`/`skill_read`/`skill_search`, `fetch`, `fetch_local`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch_local` accept GET only.
|
||||
|
||||
### Prompting (`prompting.py`)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.0"
|
||||
requires_python = ">=3.11"
|
||||
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||
groups = ["default"]
|
||||
files = [
|
||||
{file = "platformdirs-4.13.5-py3-none-any.whl", hash = "sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e"},
|
||||
{file = "platformdirs-4.13.5.tar.gz", hash = "sha256:05831adf87d392760f4b5c3a0a95579ab5a10b18b9b9bd43680dcb47544788a0"},
|
||||
{file = "platformdirs-4.14.0-py3-none-any.whl", hash = "sha256:1dadfc59a8a5d7565b3d9270ae8fa71c065595352c4277b41c11ee6c222c4b91"},
|
||||
{file = "platformdirs-4.14.0.tar.gz", hash = "sha256:83f009c1e8c5fc36869b5171755b291887bd2cade7965233cdfabd23d754720b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -532,27 +532,27 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
version = "9.2.0"
|
||||
requires_python = ">=3.10"
|
||||
summary = "pytest: simple powerful testing with Python"
|
||||
groups = ["dev"]
|
||||
dependencies = [
|
||||
"colorama>=0.4; sys_platform == \"win32\"",
|
||||
"exceptiongroup>=1; python_version < \"3.11\"",
|
||||
"iniconfig>=1.0.1",
|
||||
"packaging>=22",
|
||||
"pluggy<2,>=1.5",
|
||||
"pygments>=2.7.2",
|
||||
"tomli>=1; python_version < \"3.11\"",
|
||||
"iniconfig>=2",
|
||||
"packaging>=24",
|
||||
"pluggy<2,>=1.6",
|
||||
"pygments>=2.15",
|
||||
"tomli>=2; python_version < \"3.11\"",
|
||||
]
|
||||
files = [
|
||||
{file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"},
|
||||
{file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"},
|
||||
{file = "pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588"},
|
||||
{file = "pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
requires_python = ">=3.10"
|
||||
summary = "Pytest support for asyncio"
|
||||
groups = ["dev"]
|
||||
@@ -562,72 +562,89 @@ dependencies = [
|
||||
"typing-extensions>=4.12; python_version < \"3.13\"",
|
||||
]
|
||||
files = [
|
||||
{file = "pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1"},
|
||||
{file = "pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42"},
|
||||
{file = "pytest_asyncio-1.4.1-py3-none-any.whl", hash = "sha256:507afa78ec77c6661164ef1a2368bc7820383fd1a14b2a7f813de8b5a92949cc"},
|
||||
{file = "pytest_asyncio-1.4.1.tar.gz", hash = "sha256:e8266eb094e5ee9ffdb2068c9836f33e209ea77a970eb846933d416b5602669b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywinpty"
|
||||
version = "3.1.0"
|
||||
version = "3.1.2"
|
||||
requires_python = ">=3.10"
|
||||
summary = "Pseudo terminal support for Windows from Python."
|
||||
groups = ["default"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "pywinpty-3.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:d01178e9391366bc3325b4c49068cfce95d10d416f6ade62140b274741bc11e0"},
|
||||
{file = "pywinpty-3.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:44918ef8bc408fbf52f54db9b8c33370aee0a59cee76b071a4b4f41ad5ed89c8"},
|
||||
{file = "pywinpty-3.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:08afc428ec8765f2b8b9a9954727e1dafbefcb5912acc65952007779449650a1"},
|
||||
{file = "pywinpty-3.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:98f00e50639e9edb740eb6eedd6c0ab82959f9633029db59363d06973d00ac0d"},
|
||||
{file = "pywinpty-3.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:188923081eea840c15f37c54a29ad6871b04fddf8fbf1c4acf317d0e27bd9cac"},
|
||||
{file = "pywinpty-3.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:714e8aa138bc61dcfd26c139d8cb8e7b954ea5c09c98bb014ab9b1f5741e289c"},
|
||||
{file = "pywinpty-3.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c08a2e5fa7a62f31b5965f0be1b92c67394cd5f75dc0a033d45fadc597f0640d"},
|
||||
{file = "pywinpty-3.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:751d0c727d0e81a246aba722cf0dcb2155fd6bc7b82574b1432f81256157ec73"},
|
||||
{file = "pywinpty-3.1.0.tar.gz", hash = "sha256:14595b68054d61f2f1e90cc2b30be47be201de200ae1851c433d8cded101bce0"},
|
||||
{file = "pywinpty-3.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:980af688956fe78c87882682732e6eea45aa0b8d10a9628812e95878264d5204"},
|
||||
{file = "pywinpty-3.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:81d83678211cc6f8cd2954e98a8a18d8e5de1e0666900a1df8da70f33572d34d"},
|
||||
{file = "pywinpty-3.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fe0abcffd095642c78ba7c6abf4cc8129dce9568e97a1c9194eea23383d0de29"},
|
||||
{file = "pywinpty-3.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:30235f396856fbc03c9bb181b4e27bf50f7b355e432e5731729063d920a09867"},
|
||||
{file = "pywinpty-3.1.2-cp315-cp315-win_amd64.whl", hash = "sha256:b8c47556fbf19595898dd75f45763072ff1160b080eefabeae8cc43c5acbd720"},
|
||||
{file = "pywinpty-3.1.2-cp315-cp315-win_arm64.whl", hash = "sha256:7af19f25e769f0c70f5920ce0964c508abdfe01e676cf05d5ed935848d8c782e"},
|
||||
{file = "pywinpty-3.1.2-cp315-cp315t-win_amd64.whl", hash = "sha256:ed5c5ca288001590fbf1d5a52c6389f0f6ae7e4751bdc8b2f8f9c7249f266f9b"},
|
||||
{file = "pywinpty-3.1.2-cp315-cp315t-win_arm64.whl", hash = "sha256:0a40537712279ed74d5884b1af9037add1ed4b2e2339e9152268b582abad3a57"},
|
||||
{file = "pywinpty-3.1.2.tar.gz", hash = "sha256:03e2d2abe7c0df64db354233c83479b2b43d9fdd1badceea994259ca3819b203"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qh3"
|
||||
version = "2.0.4"
|
||||
version = "2.1.0"
|
||||
requires_python = ">=3.7"
|
||||
summary = "A lightweight and fast implementation of QUIC and HTTP/3"
|
||||
groups = ["default"]
|
||||
marker = "(platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\""
|
||||
files = [
|
||||
{file = "qh3-2.0.4-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a64bf88e94862d54a0406db92b4c51e86775e2fa5223618f92431f7ea0e668a1"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d9d22cdf5a643f3f3236b3e25462d33083f88d200425afd09958cc0d2c5ca54"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6f9d726d83942a2cb20ee127e995a0249dbea3ffe2b3772eb113acfdfd3874a1"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:968a94dcdc85219794d44fdce7b15ce6a90ca295f2574881b623ebe06850311c"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bcae368dc6b5449397f81208493361a5197df1f955d4883b164294915f83638b"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36f201b834d96759a241171a4cdc6ab7442a6c52260d1dcfb1879707b430c6d7"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f47b1f2c5fe8d70472a6134f374b6022ad6f709d6be56282ad4024d28905829"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:edc81973c3823533d75da5570e89214d87aded6205ac76cb9ae3bb9f25b5ffe6"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1964c6471d5e4c074a002bf6a95c1797576165c000d74448260426e10209bed0"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:690cf4bcd8c7baf075187d6197ab31cc933d2aa951311dc65729cb01dccc92dc"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:8449e99c275b04c866f13f24e6d656d609fe8875fedf5896201a93a60bbe1412"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:db25d8132c966ccebc294b8aeb197d2f48d14f772e675ff8282ab958009b801b"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:c03566def5374925a6270bbc1dafc8c8889b3ff3ecd14396979ea07fec9def26"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:19cd7751da3f4d581ea6f5f44510a2b30a19495daba4e632c4e96d8375856694"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-win32.whl", hash = "sha256:c6414837e375fd06f4664b276fcc3e76b17fa2431f650f52f0e41c48489d7e81"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:1923830f3e5e236bcf024ea317625346e5a3e1b08e7852e83293c1e4b3b8d7db"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:9d505d790ab10e378cf4e5c2d6447cb8b602d9cc36a4113160f9ece7bba9abc6"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ec087930c9b43d1e32c5c4cdc8d550fcb72cbfcc957c1d922859ce11b1e55663"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:406577d5fc72bed8d12b6397099b4e164948171dd0a16be8e768b31f1ef155ae"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e94ba4e929e0e54f615c4e28493980af5d82a6d928c644a2da26983df73e110"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:03015a711f8b1ab24825d6ac6d2af1874d4157ac886d236bc19f7a21e69377b6"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:fda466d648cebffccca1a68d9716b31d41eb09b284ecf17705b4a04991dbb881"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:06e8c7ad92fcb482de360af5ae00d8e4559e969d1559a5a88b68989c6bac22c4"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6c0907ab53bd1fb1cca0a309cf26df42c9862e43e40c79d0e5ac3d2b6b747689"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af34fad8554560fa38304f61708176379d5efb192da2ece5fb25dec23c304b36"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:b6760be9bc0401bf7e6c1e889181587619529f34ffcf3f8a8de5f074815dc2ab"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a29a0c05b7986ae0400a4229ca7176805cd44774675663b4ab0d4b6ebef7655c"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:3dcedaeaf791114a050762b097b18839f6cc491dbe83873137b080f7b6dd1411"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:8a8fbc4f30683a47dfb2b9eebc95e5f8b76c3a1eb818b0dd3354aad54c2d99f6"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:d777f66879bda1e2b73caa2ca624045362d3aab8b474d2db868d0f284822e24c"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1dad9bae44479f682cad2e0c0ea8cbd384ce42cd2921f1f1b4c03e1299f6acbc"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-win32.whl", hash = "sha256:45d57837ef9990c58c1776a12d551e7c88f2592a9885b6e781d30e18062b57c6"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:53f7180a74db23f5469a610a6bcdf2b29a4ed45e067a40276c83b1cead3816df"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-win_arm64.whl", hash = "sha256:79e93801a8f33e651a7751bde3e0e120b565a93d18d146bf1fc8127d1669494f"},
|
||||
{file = "qh3-2.0.4.tar.gz", hash = "sha256:72b174c9dabfccdddf050dabc3c1b49972ed7a7564896d3db558e57376187f75"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ba62fe5a0a8394e3cff6b1e205d4cdb21f18d9191193d963fc887a1f7339d3cf"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:962cdfab72c26b5fa9549c5bd1fe645ca437a449ee2c1528aff226e3264452ff"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ec9b4cc8796a7a158f05667200fbc9906ccf8ab6804bf007c61b17901035ebe"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65b2cb6aba980a5821223969d15452e102e01f1b656485b401c40dd0d4d93c8c"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:82505dbb6e5a5aa814fac47789b185b21020692c37ccfd96d428d32c9822ee5d"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5d68107e1c2bb6538bfeddd0238cfd309396acb51e0d3bd0fd7ae87390219b6"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e856c5c9393b1fb4c9e7458165ad2560ec8b0c14e6092c4e8f456dcba8ce3f1f"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f647ef9f76bd289a28baab858dcfd34efd2255a7da274a9e8f45a020be36b795"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:86ebca1a4d4874c619ef7d3eee4d699968b236e4292bb4cbd498c7054cdc5319"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:4c21a3133460449abd93a61cc675dbde893f1094f18bc6f286d44d3d3ae5d462"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:0b3d1ddf862bb9ffde56d4ef4477a0bb86fe60983d6b03b7e8869aa026c4c3d4"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:b812694d3884bf8f92ba05b02889f41ea519821fc98c2634010e5d595ec2c330"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:a80118168ebf68413b089930fcef0d6c7ba2907d61273a4fcd72414322281d75"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a6013c83b0988544ca084feea6c91b4dceb9b9a835a4c2eec0407faf820ba4d2"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:358f66b86c6ff4ac515aaf8c6848bf0e61939b075f8fa455b977d0aa3e7e6860"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1bd9d323d3a059d79c3586c00f973d64ba803234218d47ca2e8a364ec885b9dd"},
|
||||
{file = "qh3-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7644fc608c6d28012f567c5ce060b281c5a014ff60ddf4167ba9b6507ef3e576"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a07c074dcb9d58b41d1d73207357bd70e2617ed9816f36bbc5824eeef4465c1b"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6dd04f2718a3412bdebd580e35ff465128ba3d6dfa967c0ce4186f60a2df060b"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b2304539fee9c70d385bd834402ae3fbfff58fcb2793adf7fd44bd53cc983be0"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:451ae203b2670e256333170afc2ccc558fa15de172412ec02c2ea3e03854645f"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:129ff9821720341c847dffe99f8fe599914f583cfe0c725061ecfd5d578ee2b8"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a648d7ef66894113a23b0b197b9c164e8d6cd6a1175e10c1ba5bfc1d5f02eab1"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:833a93ff543f4e98b71b497841c5c3ac4c7f255d56f544f5223ffd413dca2efb"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e649419464428547e314c8048e5257d9a8cd09e15643405cc9fbe177e10cad37"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1d6e9364a537785755b5b2724c330ed59fbae8bc29df28a1a74cb83ef1defd4c"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:4c3fcb8ec0da9ad2a349a2a0b96902e0432830e6740163863911d1ea88439fad"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d15f365e295d1926bb283324c72dfb2568cdb145a2d521495b562c765a9e3507"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-musllinux_1_1_i686.whl", hash = "sha256:ba8598d3ee4c72325d099714920f23c35844a0e2f0bf20dd56b51d61545bc097"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-musllinux_1_1_riscv64.whl", hash = "sha256:67e6377dad202507d50e4af854a6a464975ed310f4ecc272f83cda73811e9b71"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:9915fd34cf3010f186c783a492c9f3ec31c0e5cad8c5e16a6fe08d2dda1defee"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:71ffe60e5a8dc67b7ecd3b2ba6a105fe4cda8e770d6fae2ff6445b557d0b4d38"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8a1a95c687bd2d26e7cb55dc14095b95ab55f1afcd8adfe1c885fa7818582ad7"},
|
||||
{file = "qh3-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:2eefd993b952bb2bff34396b7abf4cffa46f25667cba85de0dff6e083d1e83ac"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:94412f03ec7b2d27e9f238404de0ab0a293ef4eb172ce1e6f6f6fd11ab857e58"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b66c2ff3d8c5bad514f7f9362e0c9e3e4664209db5c0167278f1d90918bbd12b"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8982eae1cd88e5d7700802b82f29a7cb834d453bb557827010199a037d4062f9"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fbdfefe22dcc6222b853be72f062fc7e84d6c333294310502bf0090e20ac0cf3"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:341793106cb0f803985031690bc2e4e184bbeecbaed63d902921c7dfd07f1808"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b205fd9b5fffcf467331b82ae1f4e360581bd60886d37adf626197e75e0e0bc"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27bd2e3a03985be5e6e6a04faa1f53106c75ad607f662d54b57585a0e52121c1"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1808e123760e78482948b8e6bf5547a8a91637573b89caf165ffe948850d2ba5"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:343780d1f7e8000a6e06458e6940d0a80e29cc2be7e47b60f24f714dd17da690"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:3cb6649dee2f4d3dba008dc9d0e15a75fdb0c16b864535725f2266adc55a54e9"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:23ff27b10d88fae56fd3fb530bd93a19dcce7b3ee921b9cebc4d75767b2b71a1"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:a51ef02b036b2a06293ce87d48105a516a31694a77746062d16016a5aca6a013"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:81a03727a5ddf001fdb57251a86b950ffea47c7a3473641e2707d647d941b398"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5b7e7300dc9352f7d31b4204e6bad6c0a310a12c4b40e36a0ff347f64c8d793f"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-win32.whl", hash = "sha256:19d1b93d669177215675bd21a303763d2222bc9ba1c9c3850e1e9a5bdfe868aa"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:85be2ef6a536a687d12c018f4605a5517b151d02ea7d42a3d9f4386985c735a5"},
|
||||
{file = "qh3-2.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:0c257cef34cf990da943ffbf12c4ee2ab44d131f4b4024383edae84d19428056"},
|
||||
{file = "qh3-2.1.0.tar.gz", hash = "sha256:622e3c63f2417039785f61d6af2cb26d0f0e5935ac2b84ef85a2bf76862fe6f3"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -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.0.3"
|
||||
requires_python = ">=3.11"
|
||||
summary = "The runtime that every PyWinRT projection package runs on"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"typing-extensions>=4.12.2",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_runtime-3.2.1-cp314-cp314-win32.whl", hash = "sha256:9b6298375468ac2f6815d0c008a059fc16508c8f587e824c7936ed9216480dad"},
|
||||
{file = "winrt_runtime-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:e36e587ab5fd681ee472cd9a5995743f75107a1a84d749c64f7e490bc86bc814"},
|
||||
{file = "winrt_runtime-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:35d6241a2ebd5598e4788e69768b8890ee1eee401a819865767a1fbdd3e9a650"},
|
||||
{file = "winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea"},
|
||||
{file = "winrt_runtime-4.0.3-cp314-cp314-win32.whl", hash = "sha256:fee35995e7dfb083b5024d7a11d63ad2d2c74e7a9fc6a58dcdede52c1f0a321d"},
|
||||
{file = "winrt_runtime-4.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:03d76d06172b6f5bb6312aa24f0d25761b1e45fe2b7c3c0b4ad0cd73a06bb293"},
|
||||
{file = "winrt_runtime-4.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:9731462174346a2ede57660b9d521e41a16cd7e59890dc292c4250c42d1642a9"},
|
||||
{file = "winrt_runtime-4.0.3-cp314-cp314t-win32.whl", hash = "sha256:dc545f834a3dd2bcc69512193677f0918788ccce4871ca24937aa6f171ea7112"},
|
||||
{file = "winrt_runtime-4.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b0b06be21eb17248ce32d43a4a027ba22651065f9af5b42c3454575766879e1e"},
|
||||
{file = "winrt_runtime-4.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2b8d6a55d695e54a25e262a6d1134c510a1e291cf2c3d141990a8c209f87dc40"},
|
||||
{file = "winrt_runtime-4.0.3-cp315-cp315-win32.whl", hash = "sha256:ff7920a2d42734c545984ef671376713ce09f8e59fc9a5b420395406e2c1eea7"},
|
||||
{file = "winrt_runtime-4.0.3-cp315-cp315-win_amd64.whl", hash = "sha256:403a80372f33635851814760d1acd6d8bbdb0dedff7812203d0dd4cfba809e8b"},
|
||||
{file = "winrt_runtime-4.0.3-cp315-cp315-win_arm64.whl", hash = "sha256:52eb937ab71e39ddf798f16dfe47d572a8c097ac6d1eb2ddb700fc7977591241"},
|
||||
{file = "winrt_runtime-4.0.3-cp315-cp315t-win32.whl", hash = "sha256:325a701c70e32b31139bf650c31fa352f4eae108fb0488aad074fb7026b1e650"},
|
||||
{file = "winrt_runtime-4.0.3-cp315-cp315t-win_amd64.whl", hash = "sha256:f0e5ce8fc5e6453fbad7ed3197010586337a9a4d42fce29847d8a5573f29d8e9"},
|
||||
{file = "winrt_runtime-4.0.3-cp315-cp315t-win_arm64.whl", hash = "sha256:9580aaf167a9e1d9d790b57e9dfaa5499c8a625bd85f3872a5097915d25f1c24"},
|
||||
{file = "winrt_runtime-4.0.3.tar.gz", hash = "sha256:b04877a5b1f2ca67084d62731546f7b512c8291e2b70b95def82a1c4d0bfe993"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-applicationmodel-core"
|
||||
version = "3.2.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
version = "4!10.0.28000.2705"
|
||||
requires_python = ">=3.11"
|
||||
summary = "PyWinRT projection of the Windows.ApplicationModel.Core namespace"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"winrt-runtime~=3.2.1.0",
|
||||
"winrt-runtime<5,>=4.0.0",
|
||||
"winrt-windows-foundation-collections>=4!10.0.28000.2705",
|
||||
"winrt-windows-foundation>=4!10.0.28000.2705",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_windows_applicationmodel_core-3.2.1-cp314-cp314-win32.whl", hash = "sha256:2a940b018989ba7159ace4c93eab5f5bce303ac5325ab29eeb0effe1d4a486a7"},
|
||||
{file = "winrt_windows_applicationmodel_core-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:52f2e1cd5fa0a5b0592ddc40b423255491a40a2d6e4cf6ae766dd3b4810557ec"},
|
||||
{file = "winrt_windows_applicationmodel_core-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:a0c18662c835fbb5044a5c3ca021d8f2f3f290c6238543ed74112a18e3d7d878"},
|
||||
{file = "winrt_windows_applicationmodel_core-3.2.1.tar.gz", hash = "sha256:7cd21a4a7e25b099703a607f17ce804b5166cdf6444437440590fafea990fb46"},
|
||||
{file = "winrt_windows_applicationmodel_core-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:c6c0a854e8ab3620899ae83671bb30853183cc43d7b7ab9d2cb11fe60f2e1138"},
|
||||
{file = "winrt_windows_applicationmodel_core-4!10.0.28000.2705.tar.gz", hash = "sha256:f06920b88d030266cd2c0ef270dbcf3dec1f438e95e458933cba18103d35902b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-data-xml-dom"
|
||||
version = "3.2.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
version = "4!10.0.28000.2705"
|
||||
requires_python = ">=3.11"
|
||||
summary = "PyWinRT projection of the Windows.Data.Xml.Dom namespace"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"winrt-runtime~=3.2.1.0",
|
||||
"winrt-runtime<5,>=4.0.0",
|
||||
"winrt-windows-foundation-collections>=4!10.0.28000.2705",
|
||||
"winrt-windows-foundation>=4!10.0.28000.2705",
|
||||
"winrt-windows-storage-streams>=4!10.0.28000.2705",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win32.whl", hash = "sha256:77258030a725a32359fd2a93855cafa0e3f3f38e67afd5dfe74a8a215cbb31c4"},
|
||||
{file = "winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:1cf1b6f31fff4e4c0ae30f1643b169da72b3b053a2996010f2c3a1e26b5d4970"},
|
||||
{file = "winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:6de83f853d889444e4897413c2fa8183f86aa602edd34abf36a4fdc69db1650e"},
|
||||
{file = "winrt_windows_data_xml_dom-3.2.1.tar.gz", hash = "sha256:db81f5956bf63ce8c9d8bfb6d0794ab2849f26e62a6256b833af8de3d6451399"},
|
||||
{file = "winrt_windows_data_xml_dom-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:5784183aca5a7f0f297e21789de34312702207e767954dc96d4197588c714a5a"},
|
||||
{file = "winrt_windows_data_xml_dom-4!10.0.28000.2705.tar.gz", hash = "sha256:9dade5d5e6768077009babc965c56e0d39be53c329e81ad06867bfb2b82516dd"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-foundation"
|
||||
version = "3.2.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
version = "4!10.0.28000.2705"
|
||||
requires_python = ">=3.11"
|
||||
summary = "PyWinRT projection of the Windows.Foundation namespace"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"winrt-runtime~=3.2.1.0",
|
||||
"winrt-runtime<5,>=4.0.0",
|
||||
"winrt-windows-foundation-collections>=4!10.0.28000.2705",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win32.whl", hash = "sha256:35e973ab3c77c2a943e139302256c040e017fd6ff1a75911c102964603bba1da"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22a7ebcec0d262e60119cff728f32962a02df60471ded8b2735a655eccc0ef5"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be7fbae829b98a6a946db4fbaf356b11db1fbcbb5d4f37e7a73ac6b25de8b87"},
|
||||
{file = "winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656"},
|
||||
{file = "winrt_windows_foundation-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:b409530668b101cb17b3a6ce02061b9839665c92b64b0563c106186d87562571"},
|
||||
{file = "winrt_windows_foundation-4!10.0.28000.2705.tar.gz", hash = "sha256:eb33cc2fdf0d8c5358cb7a731563118de0f16ddc75e256b8fd83c8117a6405a0"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-foundation-collections"
|
||||
version = "3.2.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
version = "4!10.0.28000.2705"
|
||||
requires_python = ">=3.11"
|
||||
summary = "PyWinRT projection of the Windows.Foundation.Collections namespace"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"winrt-runtime~=3.2.1.0",
|
||||
"winrt-runtime<5,>=4.0.0",
|
||||
"winrt-windows-foundation>=4!10.0.28000.2705",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win32.whl", hash = "sha256:33188ed2d63e844c8adfbb82d1d3d461d64aaf78d225ce9c5930421b413c45ab"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d4cfece7e9c0ead2941e55a1da82f20d2b9c8003bb7a8853bb7f999b539f80a4"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3884146fea13727510458f6a14040b7632d5d90127028b9bfd503c6c655d0c01"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5"},
|
||||
{file = "winrt_windows_foundation_collections-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:f2169228644b86d752dca6f4ba0bb55ee27c5cc1062f3a3f0fbb375f48894bd7"},
|
||||
{file = "winrt_windows_foundation_collections-4!10.0.28000.2705.tar.gz", hash = "sha256:8d396ea352d3e57a534223314aa935c2fe3e3edaa6b38439b13d5ddfeb5a12ea"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-storage-streams"
|
||||
version = "4!10.0.28000.2705"
|
||||
requires_python = ">=3.11"
|
||||
summary = "PyWinRT projection of the Windows.Storage.Streams namespace"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"winrt-runtime<5,>=4.0.0",
|
||||
"winrt-windows-foundation-collections>=4!10.0.28000.2705",
|
||||
"winrt-windows-foundation>=4!10.0.28000.2705",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_windows_storage_streams-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:2824e516e328c9ba76ffc7926431255b124b4a9f52c656f0b1e0056a7f8ca7b8"},
|
||||
{file = "winrt_windows_storage_streams-4!10.0.28000.2705.tar.gz", hash = "sha256:9511b19e9cc588dcc81969b2871e465385f2ab37a62a9019f90dc37e03a5dd16"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-ui-notifications"
|
||||
version = "3.2.1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
version = "4!10.0.28000.2705"
|
||||
requires_python = ">=3.11"
|
||||
summary = "PyWinRT projection of the Windows.UI.Notifications namespace"
|
||||
groups = ["notify"]
|
||||
marker = "sys_platform == \"win32\""
|
||||
dependencies = [
|
||||
"winrt-runtime~=3.2.1.0",
|
||||
"winrt-runtime<5,>=4.0.0",
|
||||
"winrt-windows-foundation-collections>=4!10.0.28000.2705",
|
||||
"winrt-windows-foundation>=4!10.0.28000.2705",
|
||||
]
|
||||
files = [
|
||||
{file = "winrt_windows_ui_notifications-3.2.1-cp314-cp314-win32.whl", hash = "sha256:00a7579ca0870134c27def1418c259fb18efe5b88aa399de85b4712f1add1b55"},
|
||||
{file = "winrt_windows_ui_notifications-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:943599c727abf710ae94644b1d521e11857bd568e080e894a8be11aa717e383a"},
|
||||
{file = "winrt_windows_ui_notifications-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:2a496c7e796b537966df7b26753b20bc40c5c1fbe96b3b3051a1debc00b80f07"},
|
||||
{file = "winrt_windows_ui_notifications-3.2.1.tar.gz", hash = "sha256:eb29bf1c3306f3ceabd71d092b5505d5fe73fbc0660347dcc63ebc4204d34d21"},
|
||||
{file = "winrt_windows_ui_notifications-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:909fdfe7dc7418adc59029fa723f24db5b28836bc017d791b03e4e7cda02117c"},
|
||||
{file = "winrt_windows_ui_notifications-4!10.0.28000.2705.tar.gz", hash = "sha256:8fd5a28f81ef4b422b444b2889e2dec6a316692b7c2351a6daff39ccaf591bba"},
|
||||
]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "plyngent"
|
||||
version = "0.14.1"
|
||||
version = "0.15.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,8 @@ from .tools import ToolDefinition as ToolDefinition
|
||||
from .tools import ToolOutput as ToolOutput
|
||||
from .tools import ToolRegistry as ToolRegistry
|
||||
from .tools import ToolTag as ToolTag
|
||||
from .tools import format_approval as format_approval
|
||||
from .tools import most_specific_approval as most_specific_approval
|
||||
from .tools import schema_from_callable as schema_from_callable
|
||||
from .tools import tool as tool
|
||||
from .usage import TokenUsage as TokenUsage
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -328,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
|
||||
@@ -362,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,
|
||||
@@ -388,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
|
||||
@@ -648,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,
|
||||
@@ -809,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+155
-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,63 @@ class DangerReason:
|
||||
yolo_skip: bool = True
|
||||
|
||||
|
||||
class DenialKind(StrEnum):
|
||||
"""Why a confirmation answered no — the model should not be told it declined."""
|
||||
|
||||
DECLINED = "declined" # the human said no
|
||||
TIMEOUT = "timeout" # nobody answered before the deadline
|
||||
CANCELLED = "cancelled" # the prompt was interrupted (Ctrl+C / EOF)
|
||||
NO_PROMPT = "no_prompt" # nobody could be asked (no hook, non-interactive)
|
||||
|
||||
|
||||
# How each kind reads inside a denial message.
|
||||
_DENIAL_CLAUSES: dict[DenialKind, str] = {
|
||||
DenialKind.DECLINED: "user declined",
|
||||
DenialKind.CANCELLED: "prompt cancelled",
|
||||
DenialKind.NO_PROMPT: "no confirm hook or non-interactive (denied)",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Denial:
|
||||
"""A "no" that says which no it was: the human's, or the clock's.
|
||||
|
||||
``seconds`` is the deadline that expired (a timeout), ``comment`` what the
|
||||
human typed when they declined. A prompt returns this instead of a bare
|
||||
``False`` so the message it produces can be specific.
|
||||
"""
|
||||
|
||||
kind: DenialKind = DenialKind.DECLINED
|
||||
seconds: float | None = None
|
||||
comment: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_answer(cls, answer: object) -> Denial:
|
||||
"""Normalize a hook's refusal: a bare comment means the human declined."""
|
||||
if isinstance(answer, Denial):
|
||||
return answer
|
||||
if isinstance(answer, str) and answer.strip():
|
||||
return cls(comment=answer)
|
||||
return cls()
|
||||
|
||||
@property
|
||||
def user_comment(self) -> str | None:
|
||||
"""The comment that belongs in a message, or ``None``."""
|
||||
return (self.comment or "").strip() or None
|
||||
|
||||
def clause(self) -> str:
|
||||
"""The words a denial message carries after the reason."""
|
||||
if self.kind is DenialKind.TIMEOUT:
|
||||
if self.seconds is not None:
|
||||
return f"no answer within {self.seconds:g}s — denied by default"
|
||||
return "no answer — denied by default"
|
||||
return _DENIAL_CLAUSES[self.kind]
|
||||
|
||||
|
||||
type ToolHandler = Callable[..., Any | Awaitable[Any]]
|
||||
type DangerClassifier = Callable[[str, Mapping[str, object]], str | DangerReason | None]
|
||||
# Returns True to allow, False to deny, or a non-empty str as denial reason for the model.
|
||||
type ToolConfirmResult = bool | str
|
||||
# Returns True to allow, or a Denial (or a bare str comment) to deny.
|
||||
type ToolConfirmResult = bool | str | Denial
|
||||
type ToolConfirmHook = Callable[
|
||||
[str, Mapping[str, object], str],
|
||||
ToolConfirmResult | Awaitable[ToolConfirmResult],
|
||||
@@ -100,6 +153,66 @@ class BatchConfirmDecision:
|
||||
allowed: bool
|
||||
comment: str | None = None
|
||||
each: bool = False
|
||||
# Why the answer was no (declined vs no prompt at all); ``None`` means declined.
|
||||
denial: Denial | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Approval:
|
||||
"""How one call got past a confirmation, as the model reads it back.
|
||||
|
||||
``source`` names who answered — ``user`` (a human at a prompt), ``grant``
|
||||
(trust recorded earlier, so nobody was asked *this* time), ``yolo`` (YOLO
|
||||
mode dropped the soft confirm) or ``config`` (a configured allow, e.g.
|
||||
``[agent].allow_paths`` or ``[skills].allow_write``) — and ``scope`` how far
|
||||
that answer reaches: ``once``, ``round`` (a batch allow), ``session``,
|
||||
``process`` or ``auto`` (mode-driven, not a grant).
|
||||
"""
|
||||
|
||||
source: str
|
||||
scope: str
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
"""``user (once)`` — the words the result's first line carries."""
|
||||
return f"{self.source} ({self.scope})"
|
||||
|
||||
|
||||
# Who answered first when one call carries several records: a human prompt is the
|
||||
# closest answer, an earlier grant sits second, a config allow third, and YOLO
|
||||
# mode says the least about why the call was allowed.
|
||||
_APPROVAL_PRIORITY = {"user": 0, "grant": 1, "config": 2, "yolo": 3}
|
||||
|
||||
|
||||
def format_approval(approval: Approval) -> str:
|
||||
"""The model-facing line naming how a call was approved."""
|
||||
return f"[approval] {approval.label}"
|
||||
|
||||
|
||||
def most_specific_approval(approvals: Sequence[Approval]) -> Approval | None:
|
||||
"""The one record a result carries (ties keep the first recorded)."""
|
||||
if not approvals:
|
||||
return None
|
||||
return min(approvals, key=lambda a: _APPROVAL_PRIORITY.get(a.source, len(_APPROVAL_PRIORITY)))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Gate:
|
||||
"""What the soft gate decided for one call: a denial, else how it was allowed."""
|
||||
|
||||
denied: str | None = None
|
||||
approval: Approval | None = None
|
||||
|
||||
|
||||
def _with_approval(output: ToolOutput, approval: Approval | None) -> ToolOutput:
|
||||
"""Lead *output* with its approval line (nothing to add: unchanged).
|
||||
|
||||
An ``error:`` result carries no line: the call did not finish, so the answer
|
||||
that let it start is not what the model has to read first.
|
||||
"""
|
||||
if approval is None or output.text.startswith("error: "):
|
||||
return output
|
||||
return msgspec.structs.replace(output, text=f"{format_approval(approval)}\n{output.text}")
|
||||
|
||||
|
||||
# One prompt for a round's requests; a single request keeps ``ToolConfirmHook``.
|
||||
@@ -341,11 +454,17 @@ def _decode_tool_args(arguments_json: str) -> dict[str, object] | str:
|
||||
return {str(key): value for key, value in cast("dict[object, object]", raw_args).items()}
|
||||
|
||||
|
||||
def _denied_error(name: str, reason: str, comment: str | None) -> str:
|
||||
"""Model-facing text for a denied soft confirm (carries a user comment if one was given)."""
|
||||
if comment is not None and comment.strip():
|
||||
return f"error: tool {name!r} denied by user confirm ({reason}); user comment: {comment.strip()}"
|
||||
return f"error: tool {name!r} denied by user confirm ({reason})"
|
||||
def _denied_error(name: str, reason: str, denial: Denial | None) -> str:
|
||||
"""Model-facing text for a denied soft confirm.
|
||||
|
||||
The answer that refused the call is named: a human's no carries their
|
||||
comment, a silence carries its deadline, and neither is reported as the
|
||||
other.
|
||||
"""
|
||||
denial = denial if denial is not None else Denial()
|
||||
text = f"error: tool {name!r} denied ({reason}; {denial.clause()})"
|
||||
comment = denial.user_comment
|
||||
return f"{text}; user comment: {comment}" if comment else text
|
||||
|
||||
|
||||
def _as_danger_reason(raw: str | DangerReason | None) -> DangerReason | None:
|
||||
@@ -571,17 +690,16 @@ class ToolRegistry:
|
||||
reason: str,
|
||||
*,
|
||||
tags: ToolTag,
|
||||
) -> str | None:
|
||||
) -> _Gate:
|
||||
if self._on_confirm is None:
|
||||
return f"error: tool {name!r} denied by policy ({reason}; no confirm hook)"
|
||||
return _Gate(denied=_denied_error(name, reason, Denial(DenialKind.NO_PROMPT)))
|
||||
decision = self._on_confirm(name, args, reason)
|
||||
if inspect.isawaitable(decision):
|
||||
decision = await decision
|
||||
if decision is True:
|
||||
await self._record_grant(name, tags=tags)
|
||||
return None
|
||||
comment = decision if isinstance(decision, str) else None
|
||||
return _denied_error(name, reason, comment)
|
||||
return _Gate(approval=Approval("user", "once"))
|
||||
return _Gate(denied=_denied_error(name, reason, Denial.from_answer(decision)))
|
||||
|
||||
async def _apply_batch_decision(
|
||||
self,
|
||||
@@ -590,11 +708,12 @@ class ToolRegistry:
|
||||
decision: BatchConfirmDecision,
|
||||
*,
|
||||
tags: ToolTag,
|
||||
) -> str | None:
|
||||
) -> _Gate:
|
||||
if decision.allowed:
|
||||
await self._record_grant(name, tags=tags)
|
||||
return None
|
||||
return _denied_error(name, reason, decision.comment)
|
||||
return _Gate(approval=Approval("user", "round"))
|
||||
denial = decision.denial if decision.denial is not None else Denial(comment=decision.comment)
|
||||
return _Gate(denied=_denied_error(name, reason, denial))
|
||||
|
||||
def _prepare_confirm(self, name: str, arguments_json: str) -> ToolConfirmRequest | None:
|
||||
"""Soft-confirm request for one call, or ``None`` when it needs none.
|
||||
@@ -678,7 +797,7 @@ class ToolRegistry:
|
||||
*,
|
||||
tags: ToolTag,
|
||||
decision: BatchConfirmDecision | None = None,
|
||||
) -> str | None:
|
||||
) -> _Gate:
|
||||
"""Soft-confirm gate driven by danger reason + tags + grants + YOLO.
|
||||
|
||||
Pipeline (soft gate only; hard denylists stay in tool handlers)::
|
||||
@@ -690,20 +809,23 @@ class ToolRegistry:
|
||||
else → on_confirm (a tool without the YOLO tag, or a reason YOLO
|
||||
may not drop — a privilege escalation — still prompts
|
||||
under YOLO mode)
|
||||
|
||||
Every allow names how it happened, so the result can say it; a call that
|
||||
needed no confirmation carries no approval.
|
||||
"""
|
||||
if self._danger is None:
|
||||
return None
|
||||
return _Gate()
|
||||
reason = _as_danger_reason(self._danger(name, args))
|
||||
if reason is None:
|
||||
return None
|
||||
return _Gate()
|
||||
if self._yolo and (tags & ToolTag.YOLO) and reason.yolo_skip:
|
||||
return None
|
||||
return _Gate(approval=Approval("yolo", "auto"))
|
||||
|
||||
async with self._confirm_lock:
|
||||
if decision is not None:
|
||||
return await self._apply_batch_decision(name, reason.text, decision, tags=tags)
|
||||
if self._grant_allows(name, tags=tags):
|
||||
return None
|
||||
return _Gate(approval=Approval("grant", "session"))
|
||||
return await self._prompt_soft_confirm(name, args, reason.text, tags=tags)
|
||||
|
||||
async def execute(
|
||||
@@ -746,6 +868,8 @@ class ToolRegistry:
|
||||
return ToolOutput(text=missing_error)
|
||||
|
||||
async def _run() -> ToolOutput:
|
||||
from plyngent.tools.context import collect_approvals
|
||||
|
||||
missing = self._check_state_tags(definition)
|
||||
if missing is not None:
|
||||
return ToolOutput(text=missing)
|
||||
@@ -754,11 +878,16 @@ class ToolRegistry:
|
||||
async with AsyncExitStack() as held:
|
||||
for lock in self._write_locks_for(definition, args):
|
||||
await held.enter_async_context(lock)
|
||||
denied = await self._maybe_confirm(name, args, tags=definition.tags, decision=decision)
|
||||
if denied is not None:
|
||||
return ToolOutput(text=denied)
|
||||
result = await self._invoke(definition, args)
|
||||
return _without_refused_images(result if isinstance(result, ToolOutput) else ToolOutput(text=result))
|
||||
with collect_approvals() as recorded:
|
||||
gate = await self._maybe_confirm(name, args, tags=definition.tags, decision=decision)
|
||||
if gate.denied is not None:
|
||||
return ToolOutput(text=gate.denied)
|
||||
result = await self._invoke(definition, args)
|
||||
output = _without_refused_images(result if isinstance(result, ToolOutput) else ToolOutput(text=result))
|
||||
# The gate's answer and whatever the handler asked on its own;
|
||||
# the result carries the one closest to a human (see Approval).
|
||||
approvals = [*([gate.approval] if gate.approval is not None else []), *recorded]
|
||||
return _with_approval(output, most_specific_approval(approvals))
|
||||
|
||||
# Bind host-provided state for tools that read contextvars (todo, workspace).
|
||||
# Tag enforcement remains gated by ``auto_bind_state``; binding is always useful
|
||||
|
||||
@@ -808,6 +808,66 @@ def _resume_detail(_args_json: str, result: str) -> str:
|
||||
return _range_detail(result) or _pretty_segments(("(done)", "green"))
|
||||
|
||||
|
||||
def _doc_read_prefix(args_json: str) -> str:
|
||||
document_id = _json_str_arg(args_json, "document_id") or "?"
|
||||
return _pretty_prefix(f"* Doc Read '{document_id}' ")
|
||||
|
||||
|
||||
def _doc_read_detail(_args_json: str, result: str) -> str:
|
||||
"""``read_document`` outcome: the line range read, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
return _range_detail(result) or _pretty_segments(("(done)", "green"))
|
||||
|
||||
|
||||
def _doc_search_prefix(args_json: str) -> str:
|
||||
pattern = _json_str_arg(args_json, "pattern") or "?"
|
||||
document_id = _json_str_arg(args_json, "document_id") or "?"
|
||||
return _pretty_prefix(f"* Doc Search '{_preview(pattern, _ASK_SUBJECT_PREVIEW)}' in '{document_id}' ")
|
||||
|
||||
|
||||
def _doc_search_detail(_args_json: str, result: str) -> str:
|
||||
"""``search_document`` outcome: the match count, none, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
if result == "(no matches)":
|
||||
return _pretty_segments(("(no matches)", "dim"))
|
||||
matches = [line for line in result.splitlines() if line and not line.startswith("...[truncated")]
|
||||
unit = "match" if len(matches) == 1 else "matches"
|
||||
return _pretty_segments((f"({len(matches)} {unit})", None))
|
||||
|
||||
|
||||
def _doc_info_prefix(args_json: str) -> str:
|
||||
document_id = _json_str_arg(args_json, "document_id") or "?"
|
||||
return _pretty_prefix(f"* Doc Info '{document_id}' ")
|
||||
|
||||
|
||||
def _doc_info_detail(_args_json: str, result: str) -> str:
|
||||
"""``document_info`` outcome: the described size, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
fields = _status_fields(result)
|
||||
lines = fields.get("lines", "")
|
||||
chars = fields.get("chars", "")
|
||||
text = f"{lines} lines, {chars} chars" if lines and chars else "done"
|
||||
return _pretty_segments((f"({text})", None))
|
||||
|
||||
|
||||
def _doc_list_prefix(_args_json: str) -> str:
|
||||
return _pretty_prefix("* Doc List ")
|
||||
|
||||
|
||||
def _doc_list_detail(_args_json: str, result: str) -> str:
|
||||
"""``list_documents`` outcome: the document count, none, or an error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
if result == "(no documents)":
|
||||
return _pretty_segments(("(no documents)", "dim"))
|
||||
count = len(result.splitlines())
|
||||
unit = "document" if count == 1 else "documents"
|
||||
return _pretty_segments((f"({count} {unit})", None))
|
||||
|
||||
|
||||
def _vcs_kind_detail(_args_json: str, result: str) -> str:
|
||||
"""``vcs_kind`` outcome: the detected kind, or an error."""
|
||||
if result.startswith("error:"):
|
||||
@@ -952,6 +1012,10 @@ _PRETTY_LINES: dict[str, PrettyLine] = {
|
||||
"glob_paths": PrettyLine(prefix=_glob_prefix, detail=_glob_detail),
|
||||
"regex_files": PrettyLine(prefix=_regex_prefix, detail=_regex_detail),
|
||||
"get_truncated": PrettyLine(prefix=_resume_prefix, detail=_resume_detail),
|
||||
"read_document": PrettyLine(prefix=_doc_read_prefix, detail=_doc_read_detail),
|
||||
"search_document": PrettyLine(prefix=_doc_search_prefix, detail=_doc_search_detail),
|
||||
"document_info": PrettyLine(prefix=_doc_info_prefix, detail=_doc_info_detail),
|
||||
"list_documents": PrettyLine(prefix=_doc_list_prefix, detail=_doc_list_detail),
|
||||
"run_argv": PrettyLine(prefix=_run_argv_prefix, detail=_run_argv_detail),
|
||||
"run_argv_batch": PrettyLine(prefix=_run_argv_batch_prefix, detail=_run_argv_batch_detail),
|
||||
"fetch": PrettyLine(prefix=_fetch_prefix, detail=_fetch_detail),
|
||||
@@ -1005,9 +1069,21 @@ def _pretty_line_for(name: str) -> PrettyLine | None:
|
||||
return _PRETTY_LINES.get(name) or _mcp_line(name)
|
||||
|
||||
|
||||
_APPROVAL_LINE = re.compile(r"\A\[approval\] [^\n]*\n")
|
||||
|
||||
|
||||
def _without_approval_line(result: str) -> str:
|
||||
"""The tool's own text, without the leading ``[approval]`` line.
|
||||
|
||||
That line is for the model (the human already saw the prompt); a pretty
|
||||
detail reads the result it knows, not a header in front of it.
|
||||
"""
|
||||
return _APPROVAL_LINE.sub("", result, count=1)
|
||||
|
||||
|
||||
def _pretty_parts(line: PrettyLine, args_json: str, result: str) -> tuple[str, str]:
|
||||
"""Prefix + detail for a resolved pretty tool call."""
|
||||
return line.prefix(args_json), line.detail(args_json, result)
|
||||
return line.prefix(args_json), line.detail(args_json, _without_approval_line(result))
|
||||
|
||||
|
||||
def _echo_stream(text: str) -> None:
|
||||
|
||||
@@ -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/"]
|
||||
|
||||
+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.
|
||||
|
||||
@@ -575,7 +575,8 @@ def status_cmd(state: ReplState) -> None:
|
||||
f"yolo={state.effective_yolo()}\n"
|
||||
f"context_tokens={ctx_tilde}{ctx_tokens}/{ctx_budget} ({ctx_tag}) "
|
||||
f"context_chars={ctx_chars} "
|
||||
f"tool_result_max={state.agent.max_tool_result_chars}\n"
|
||||
f"tool_result_max={state.agent.max_tool_result_chars} "
|
||||
f"tool_result_store_max={state.agent.hard_max_tool_result_chars}\n"
|
||||
f"peak_prompt_tokens={state.agent.peak_prompt_tokens} "
|
||||
f"reminder_band={state.agent.reminder_last_band} "
|
||||
f"reminder_every={state.agent.directive_reminder_tokens or 'off'}\n"
|
||||
|
||||
@@ -33,6 +33,7 @@ from plyngent.tools.view import MemoryViewStore, session_data_view
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from plyngent.agent import Denial
|
||||
from plyngent.config.models import Provider
|
||||
from plyngent.config.store import ConfigStore
|
||||
from plyngent.memory import MemoryStore
|
||||
@@ -228,6 +229,18 @@ class ReplState:
|
||||
if hasattr(self, "agent") and self.agent.tools is not None:
|
||||
self.agent.tools.set_session_state(self.session_state)
|
||||
self.agent.tools.set_instance_state(self.instance_state)
|
||||
self._bind_document_store()
|
||||
|
||||
def _bind_document_store(self) -> None:
|
||||
"""Expose this session's tool results to the document tools.
|
||||
|
||||
The store resolves a ``document_id`` against the agent's live history,
|
||||
which reads whole (uncapped) tool results and re-reads whatever a resume
|
||||
loaded from the durable store.
|
||||
"""
|
||||
from plyngent.tools.documents import DocumentStore
|
||||
|
||||
self.instance_state.extras["documents"] = DocumentStore(lambda: self.agent.messages)
|
||||
|
||||
async def persist_todo_stack(self) -> None:
|
||||
"""Write the in-memory todo stack to the active session row."""
|
||||
@@ -284,13 +297,16 @@ class ReplState:
|
||||
mode: AccessMode,
|
||||
reason: str,
|
||||
timeout_seconds: float,
|
||||
) -> AccessDecision | str | None:
|
||||
"""Directory-access confirm: YOLO/``--yes`` auto-approves, else ask."""
|
||||
from plyngent.cli.limits import prompt_directory_access_confirm
|
||||
from plyngent.tools.access import AccessDecision as Decision
|
||||
) -> AccessDecision | str | Denial:
|
||||
"""Directory-access confirm: always ask, whatever YOLO mode says.
|
||||
|
||||
An out-of-workspace path is a grant on the filesystem rather than one
|
||||
risky call, so ``--yes`` / ``/yolo`` do not answer it — the command
|
||||
denylist and a skill write are not skipped either. A non-interactive run
|
||||
denies.
|
||||
"""
|
||||
from plyngent.cli.limits import prompt_directory_access_confirm
|
||||
|
||||
if self.effective_yolo() != "off":
|
||||
return Decision(mode, persist=False)
|
||||
return prompt_directory_access_confirm(path, mode, reason, timeout_seconds)
|
||||
|
||||
def _install_config_access(self) -> None:
|
||||
@@ -487,6 +503,7 @@ class ReplState:
|
||||
stream=self.stream_enabled,
|
||||
system_prompt=system_prompt,
|
||||
max_tool_result_chars=agent_cfg.max_tool_result_chars,
|
||||
hard_max_tool_result_chars=agent_cfg.hard_max_tool_result_chars,
|
||||
parallel_tools=agent_cfg.parallel_tools,
|
||||
max_context_tokens=agent_cfg.max_context_tokens,
|
||||
todo_stack=self.todo_stack,
|
||||
|
||||
@@ -64,8 +64,9 @@ skill file the same way.
|
||||
(`*.py` / `**/*.py` fail with `invalid regex`), and a literal file/dir `path` \
|
||||
(no glob expansion, so `src/**/*.py` fails with `path does not exist`); there is \
|
||||
no file-type filter, so narrow with `path` and filter hits yourself.
|
||||
- Truncated results (any tool) carry a `truncate_token=...`; use `get_truncated` \
|
||||
with it to keep reading (chains through truncations).
|
||||
- A long tool result is stored whole and trimmed on each request with a \
|
||||
`document_id=...` marker; read the rest with `read_document` (line window) or \
|
||||
`search_document` (regex), no re-run.
|
||||
|
||||
### Commands
|
||||
- Prefer `run_argv` / `run_argv_batch` (argv lists, no shell) over `bash -c` or similar.
|
||||
@@ -84,7 +85,7 @@ for ordered pipelines (`pipe_out` / `mix_stderr` as needed).
|
||||
(GET/POST/PUT/DELETE) from this host for localhost/LAN, APIs, custom headers, and \
|
||||
status/redirect inspection.
|
||||
- `offset` (0-based) skips leading body characters to page through a large body; \
|
||||
truncated bodies resume with `get_truncated` and are not re-requested.
|
||||
a long body is stored whole, so its `document_id` reads the rest with no new request.
|
||||
- Set `user_agent` (or a User-Agent header) on `fetch_local` when the remote expects a \
|
||||
specific client; otherwise a small default is used. Never rely on shell to spoof identity.
|
||||
- Private/loopback/LAN hosts need an explicit human policy allow (not skipped by YOLO); \
|
||||
@@ -151,6 +152,11 @@ class AgentConfig(Struct, omit_defaults=True):
|
||||
# into the agent system prompt when tools are on. false = ignore server text.
|
||||
mcp_instructions: bool = True
|
||||
max_tool_result_chars: int = 32_000
|
||||
# Storage ceiling for one tool result kept whole in its message. The wire cap
|
||||
# above trims a result on the request copy and names a document for the stored
|
||||
# remainder; a result above this ceiling keeps its head behind a notice saying
|
||||
# how much was dropped (the document cannot serve that tail).
|
||||
hard_max_tool_result_chars: int = 2_000_000
|
||||
parallel_tools: bool = True
|
||||
confirm_destructive: bool = True
|
||||
path_denylist: list[str] = field(default_factory=list)
|
||||
|
||||
@@ -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,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]:
|
||||
|
||||
@@ -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)
|
||||
@@ -11,7 +11,7 @@ from plyngent.tools.workspace import AccessMode, resolve_path
|
||||
_LINENO_WIDTH = 6
|
||||
|
||||
|
||||
def _format_with_lineno(lines: list[str], *, start_lineno: int) -> str:
|
||||
def format_with_lineno(lines: list[str], *, start_lineno: int) -> str:
|
||||
"""Prefix each line with a 1-based absolute line number (``edit_lineno`` style)."""
|
||||
out: list[str] = []
|
||||
for index, line in enumerate(lines):
|
||||
@@ -94,7 +94,7 @@ async def read_file(
|
||||
if start >= len(lines):
|
||||
return ""
|
||||
slice_lines = lines[start:end]
|
||||
body = _format_with_lineno(slice_lines, start_lineno=start + 1) if with_lineno else "".join(slice_lines)
|
||||
body = format_with_lineno(slice_lines, start_lineno=start + 1) if with_lineno else "".join(slice_lines)
|
||||
char_start = len("".join(lines[:start]))
|
||||
if max_chars >= 1:
|
||||
body, token = truncate_with_token(
|
||||
|
||||
@@ -9,7 +9,6 @@ from plyngent.tools.net.client import FetchResult, http_fetch
|
||||
from plyngent.tools.net.policy import (
|
||||
DEFAULT_MAX_BODY_CHARS_IN,
|
||||
DEFAULT_MAX_BYTES,
|
||||
DEFAULT_MAX_CHARS,
|
||||
DEFAULT_MAX_REDIRECTS,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
FetchPolicyError,
|
||||
@@ -18,7 +17,6 @@ from plyngent.tools.net.policy import (
|
||||
parse_fetch_url,
|
||||
soft_confirm_reason,
|
||||
)
|
||||
from plyngent.tools.truncate_token import truncate_to_memory
|
||||
from plyngent.tools.workspace import image_input_refusal
|
||||
|
||||
|
||||
@@ -35,15 +33,13 @@ def format_fetch_result(
|
||||
security: str,
|
||||
warnings: list[str],
|
||||
body_kind: str,
|
||||
max_chars: int,
|
||||
offset: int = 0,
|
||||
) -> str:
|
||||
start = max(0, offset)
|
||||
# The body is already in hand, so the remainder is kept in memory rather than
|
||||
# re-requested: resuming a fetch must not cost another round trip (latency)
|
||||
# nor re-hit the remote (rate limits / risk control).
|
||||
body, token = truncate_to_memory(body_text[start:], max_chars)
|
||||
char_truncated = token is not None
|
||||
# The body is kept whole in the tool message (the request-prep pass trims it
|
||||
# on the wire and names its document), so resuming reads the message rather
|
||||
# than re-hitting the remote.
|
||||
body = body_text[start:]
|
||||
warn_line = "; ".join(warnings) if warnings else ""
|
||||
parts = [
|
||||
f"status={status}",
|
||||
@@ -52,7 +48,7 @@ def format_fetch_result(
|
||||
f"content_type={content_type}",
|
||||
f"body_kind={body_kind}",
|
||||
f"bytes={body_bytes}",
|
||||
f"truncated={'true' if truncated or char_truncated else 'false'}",
|
||||
f"truncated={'true' if truncated else 'false'}",
|
||||
f"redirects={redirects}",
|
||||
f"security={security}",
|
||||
]
|
||||
@@ -73,19 +69,18 @@ async def fetch_local(
|
||||
user_agent: str | None = None,
|
||||
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
max_bytes: int = DEFAULT_MAX_BYTES,
|
||||
max_chars: int = DEFAULT_MAX_CHARS,
|
||||
max_redirects: int = DEFAULT_MAX_REDIRECTS,
|
||||
follow_redirects: bool = True,
|
||||
allow_http_downgrade: bool = False,
|
||||
offset: int = 0,
|
||||
) -> str | ToolOutput:
|
||||
"""HTTP request (GET/POST/PUT/DELETE); return status, metadata, and truncated body.
|
||||
"""HTTP request (GET/POST/PUT/DELETE); return status, metadata, and the body.
|
||||
|
||||
Use for public docs/APIs or (after human policy allow) local/LAN servers.
|
||||
``offset`` skips that many leading characters of the body (0-based), for
|
||||
paging through a body larger than ``max_chars``; a body truncated by
|
||||
``max_chars`` keeps its remainder in memory instead, resumable via its
|
||||
``truncate_token`` without another request.
|
||||
paging through a body the wire cap trimmed; a trimmed body keeps its
|
||||
remainder in the tool message, so its ``document_id`` reads the rest with no
|
||||
new request.
|
||||
``user_agent`` sets User-Agent when provided and takes precedence over a
|
||||
User-Agent entry in ``headers``. A headers-only User-Agent is kept as-is.
|
||||
When both omit UA, a small default is used.
|
||||
@@ -138,7 +133,6 @@ async def fetch_local(
|
||||
security=result.security,
|
||||
warnings=result.warnings,
|
||||
body_kind=result.body_kind,
|
||||
max_chars=max_chars,
|
||||
offset=offset,
|
||||
)
|
||||
return _attach_image(text, result)
|
||||
|
||||
@@ -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,26 +18,9 @@ from plyngent.tools.workspace import (
|
||||
)
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 30
|
||||
DEFAULT_MAX_OUTPUT_CHARS = 32_000
|
||||
DEFAULT_MAX_BATCH_STEPS = 20
|
||||
|
||||
|
||||
def truncate_output(text: str, label: str, max_chars: int = DEFAULT_MAX_OUTPUT_CHARS) -> str:
|
||||
"""Cap a per-stream output block; embed a truncate token when cut.
|
||||
|
||||
``label`` (stdout/stderr/batch) is kept for call-site clarity but the
|
||||
marker itself is the uniform ``[Truncated (N chars max.; M omitted).
|
||||
truncate_token=...]`` (no label), matching every other truncation site.
|
||||
Returns ``text`` unchanged when it fits.
|
||||
"""
|
||||
del label
|
||||
if max_chars < 1 or len(text) <= max_chars:
|
||||
return text
|
||||
from plyngent.tools.truncate_token import truncate_generic
|
||||
|
||||
return truncate_generic(text, max_chars)
|
||||
|
||||
|
||||
def format_command_result(
|
||||
*,
|
||||
returncode: int | None,
|
||||
@@ -53,9 +36,9 @@ def format_command_result(
|
||||
f"cwd={workdir_display}",
|
||||
f"cmd={shlex.join(command)}",
|
||||
"--- stdout ---",
|
||||
truncate_output(stdout, "stdout"),
|
||||
stdout,
|
||||
"--- stderr ---",
|
||||
truncate_output(stderr, "stderr"),
|
||||
stderr,
|
||||
]
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@@ -9,13 +9,14 @@ from plyngent.tools.workspace import WorkspaceError
|
||||
|
||||
from .command_exec import (
|
||||
DEFAULT_MAX_BATCH_STEPS,
|
||||
DEFAULT_MAX_OUTPUT_CHARS,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
CommandStepResult,
|
||||
execute_argv,
|
||||
truncate_output,
|
||||
)
|
||||
|
||||
# Execution budget: stop the batch once accumulated output crosses this factor
|
||||
# of the base budget, so a runaway batch cannot keep spawning huge-output steps.
|
||||
BATCH_OUTPUT_BUDGET_CHARS = 32_000
|
||||
BATCH_OUTPUT_SOFT_CAP_FACTOR = 4
|
||||
|
||||
|
||||
@@ -102,9 +103,9 @@ def _format_step(index: int, result: CommandStepResult, *, pipe_out: bool) -> st
|
||||
f"pipe_out={'true' if pipe_out else 'false'}",
|
||||
f"mix_stderr={'true' if result.mix_stderr else 'false'}",
|
||||
"--- stdout ---",
|
||||
truncate_output(result.stdout, "stdout"),
|
||||
result.stdout,
|
||||
"--- stderr ---",
|
||||
truncate_output(result.stderr, "stderr"),
|
||||
result.stderr,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -147,7 +148,7 @@ async def _run_batch_steps(
|
||||
effective_stop = stop_on_error if step["stop_on_error"] is None else bool(step["stop_on_error"])
|
||||
if failed and effective_stop:
|
||||
return results, True
|
||||
if total_chars > DEFAULT_MAX_OUTPUT_CHARS * BATCH_OUTPUT_SOFT_CAP_FACTOR:
|
||||
if total_chars > BATCH_OUTPUT_BUDGET_CHARS * BATCH_OUTPUT_SOFT_CAP_FACTOR:
|
||||
return results, True
|
||||
del index # used for clarity in loop only
|
||||
|
||||
@@ -178,7 +179,9 @@ async def run_argv_batch(
|
||||
}
|
||||
|
||||
``pipe_out`` is set on the **provider** step. Last-step ``pipe_out`` is an error.
|
||||
Default ``stop_on_error`` is true. Max 20 steps; total output budget 32k chars.
|
||||
Default ``stop_on_error`` is true. Max 20 steps; the batch stops early once
|
||||
accumulated output passes the run budget (128k chars). Each step's output is
|
||||
kept whole for the model; a long result carries a ``document_id`` to read on.
|
||||
"""
|
||||
try:
|
||||
raw_steps = _normalize_steps(steps)
|
||||
@@ -195,8 +198,6 @@ async def run_argv_batch(
|
||||
body = "\n".join(
|
||||
_format_step(i, result, pipe_out=bool(step["pipe_out"])) for i, (step, result) in enumerate(results)
|
||||
)
|
||||
if len(body) > DEFAULT_MAX_OUTPUT_CHARS:
|
||||
body = truncate_output(body, "batch")
|
||||
last_exit = results[-1][1].exit_code if results else ""
|
||||
return "\n".join(
|
||||
[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1065,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(
|
||||
@@ -1093,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()]
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ def _danger(name: str, _args: Mapping[str, object]) -> str | None:
|
||||
return f"mutate {name}"
|
||||
|
||||
|
||||
def _approved(scope: str, text: str) -> str:
|
||||
"""A result as the model reads it once a confirmation let the call run."""
|
||||
return f"[approval] {scope}\n{text}"
|
||||
|
||||
|
||||
async def test_loop_round_prompts_once_before_running_its_calls() -> None:
|
||||
"""The loop aggregates a round's confirms and applies one answer to each call."""
|
||||
batch_prompts: list[int] = []
|
||||
@@ -54,7 +59,10 @@ async def test_loop_round_prompts_once_before_running_its_calls() -> None:
|
||||
|
||||
assert batch_prompts == [2]
|
||||
assert single_prompts == []
|
||||
assert [getattr(message, "content", None) for message in messages] == ["stamped a", "stamped b"]
|
||||
assert [getattr(message, "content", None) for message in messages] == [
|
||||
_approved("user (round)", "stamped a"),
|
||||
_approved("user (round)", "stamped b"),
|
||||
]
|
||||
assert len(events) == len(tool_calls) * 2 # call event + result event per call
|
||||
|
||||
|
||||
@@ -88,7 +96,11 @@ async def test_round_asks_once_for_many_mutating_calls() -> None:
|
||||
results = [
|
||||
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
|
||||
]
|
||||
assert results == ["stamped a", "stamped b", "stamped c"]
|
||||
assert results == [
|
||||
_approved("user (round)", "stamped a"),
|
||||
_approved("user (round)", "stamped b"),
|
||||
_approved("user (round)", "stamped c"),
|
||||
]
|
||||
assert ran == ["a", "b", "c"]
|
||||
assert single_prompts == []
|
||||
|
||||
@@ -129,7 +141,11 @@ async def test_yolo_calls_are_left_out_of_the_prompt() -> None:
|
||||
results = [
|
||||
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
|
||||
]
|
||||
assert results == ["stamped a", "stamped b", "stamped c"]
|
||||
assert results == [
|
||||
_approved("yolo (auto)", "stamped a"),
|
||||
_approved("user (round)", "stamped b"),
|
||||
_approved("user (round)", "stamped c"),
|
||||
]
|
||||
assert single_prompts == []
|
||||
|
||||
|
||||
@@ -153,7 +169,7 @@ async def test_single_request_keeps_the_one_call_prompt() -> None:
|
||||
reg = ToolRegistry([stamp], danger=_danger, on_confirm=on_single, on_confirm_batch=on_batch)
|
||||
assert await reg.prepare_batch_confirm([("stamp", '{"path": "a"}')]) == [None]
|
||||
assert batch_prompts == []
|
||||
assert await reg.execute("stamp", '{"path": "a"}') == "stamped a"
|
||||
assert await reg.execute("stamp", '{"path": "a"}') == _approved("user (once)", "stamped a")
|
||||
assert single_prompts == ["stamp"]
|
||||
|
||||
|
||||
@@ -206,7 +222,8 @@ async def test_batch_deny_reaches_every_call_with_one_comment() -> None:
|
||||
results = [
|
||||
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
|
||||
]
|
||||
assert all(result.startswith("error: tool 'stamp' denied by user confirm") for result in results)
|
||||
assert all(result.startswith("error: tool 'stamp' denied (") for result in results)
|
||||
assert all("user declined" in result for result in results)
|
||||
assert all("user comment: not these two" in result for result in results)
|
||||
assert ran == []
|
||||
|
||||
@@ -254,7 +271,10 @@ async def test_manual_hands_the_round_back_to_the_per_call_prompt() -> None:
|
||||
decisions = await reg.prepare_batch_confirm(calls)
|
||||
|
||||
assert decisions == [None, None]
|
||||
assert [await reg.execute(name, arguments) for name, arguments in calls] == ["stamped a", "stamped b"]
|
||||
assert [await reg.execute(name, arguments) for name, arguments in calls] == [
|
||||
_approved("user (once)", "stamped a"),
|
||||
_approved("user (once)", "stamped b"),
|
||||
]
|
||||
assert batch_prompts == [2]
|
||||
assert single_prompts == ["stamp", "stamp"]
|
||||
|
||||
@@ -274,7 +294,10 @@ async def test_without_a_batch_hook_every_call_prompts_on_its_own() -> None:
|
||||
reg = ToolRegistry([stamp], danger=_danger, on_confirm=on_single)
|
||||
calls = [("stamp", '{"path": "a"}'), ("stamp", '{"path": "b"}')]
|
||||
assert await reg.prepare_batch_confirm(calls) == [None, None]
|
||||
assert [await reg.execute(name, arguments) for name, arguments in calls] == ["stamped a", "stamped b"]
|
||||
assert [await reg.execute(name, arguments) for name, arguments in calls] == [
|
||||
_approved("user (once)", "stamped a"),
|
||||
_approved("user (once)", "stamped b"),
|
||||
]
|
||||
assert single_prompts == ["stamp", "stamp"]
|
||||
|
||||
|
||||
@@ -306,8 +329,9 @@ async def test_batch_allow_records_the_trustable_grant() -> None:
|
||||
calls = [("trust_me", '{"path": "a"}'), ("trust_me", '{"path": "b"}')]
|
||||
decisions = await reg.prepare_batch_confirm(calls)
|
||||
for path, decision in zip(("a", "b"), decisions, strict=True):
|
||||
assert await reg.execute("trust_me", f'{{"path": "{path}"}}', decision=decision) == f"done {path}"
|
||||
approve = _approved("user (round)", f"done {path}")
|
||||
assert await reg.execute("trust_me", f'{{"path": "{path}"}}', decision=decision) == approve
|
||||
assert has_grant(session, "trust_me")
|
||||
# The TRUSTABLE grant, not the batch answer, keeps later calls unchecked.
|
||||
assert await reg.execute("trust_me", '{"path": "c"}') == "done c"
|
||||
assert await reg.execute("trust_me", '{"path": "c"}') == _approved("grant (session)", "done c")
|
||||
assert single_prompts == []
|
||||
|
||||
@@ -1018,6 +1018,17 @@ def test_every_builtin_tool_has_a_pretty_line() -> None:
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_a_confirmed_result_keeps_its_pretty_detail() -> None:
|
||||
"""The approval line leads the model's text; a pretty detail reads past it."""
|
||||
from plyngent.cli.display import _pretty_line_for, _pretty_parts
|
||||
|
||||
line = _pretty_line_for("write_file")
|
||||
assert line is not None
|
||||
approved = "[approval] user (once)\nwrote 120 characters to a.txt"
|
||||
_prefix, detail = _pretty_parts(line, '{"path": "a.txt", "overwrite": true}', approved)
|
||||
assert "(120 chars)" in detail
|
||||
|
||||
|
||||
async def test_pretty_skill_tools(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(
|
||||
_aiter(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -192,14 +192,14 @@ async def test_run_argv_timeout_keeps_partial_output(workspace: object) -> None:
|
||||
assert "partial-out" in out
|
||||
|
||||
|
||||
async def test_run_argv_stdout_cap_embeds_token(workspace: object) -> None:
|
||||
async def test_run_argv_returns_the_full_stdout(workspace: object) -> None:
|
||||
del workspace
|
||||
# Per-stream cap default is 32000 chars: a bigger stdout embeds a token.
|
||||
# The result is stored whole; the request-prep pass caps it on the wire and
|
||||
# names its document, so nothing is cut at the tool.
|
||||
out = await call_async(run_argv, _py("print('x' * 40000)"))
|
||||
assert "truncate_token=" in out
|
||||
token_line = next(ln for ln in out.splitlines() if "truncate_token=" in ln)
|
||||
assert "[Truncated (32000 chars max.;" in token_line
|
||||
assert "[Truncated" in out
|
||||
body = out.split("--- stdout ---", 1)[1].split("--- stderr ---", 1)[0].strip()
|
||||
assert body == "x" * 40000
|
||||
assert "truncate_token=" not in out
|
||||
|
||||
|
||||
async def test_run_argv_small_stdout_no_marker(workspace: object) -> None:
|
||||
|
||||
@@ -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