mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0aba700e2 | |||
| 729627eee9 | |||
| 78b764f70e | |||
| 0dd70ff31b | |||
| 86596633e8 | |||
| f4c0c45403 | |||
| f5cff9d90d | |||
| eef1d3cd29 | |||
| 9742a1ae7a | |||
| cfe36b024b | |||
| 37e130a7dc | |||
| cb19652b2a | |||
| 7a4205a588 | |||
| 2054423ebb | |||
| 3c8ca5fcde | |||
| 3cd95504c6 |
@@ -28,6 +28,10 @@ GitHub Actions runs `ruff check`, `ruff format --check`, `basedpyright`, then `p
|
||||
|
||||
## Architecture
|
||||
|
||||
### Conversation units: turn vs round
|
||||
|
||||
A **round** is one model round trip: the request, the single assistant message it produced, and the tool batch that message asked for (one `UsageEvent` each). A **turn** is one user message through the explicit stop — the first round whose assistant message asks for no tools — and holds 0..N rounds; `retry` continues the *same* turn instead of opening a new one. `max_rounds` (32 by default) bounds rounds per turn. Turn-scoped state (read-before-edit, `yyy` auto-continue, `/yolo once`, todo-nag "touched" flag, `last_turn_usage`/`last_turn_rounds`) resets on the next user message; session-scoped state (rows, todo stack, grants) does not. Details: [doc/turns-and-rounds.md](doc/turns-and-rounds.md).
|
||||
|
||||
### Data modeling: `msgspec.Struct`
|
||||
|
||||
All protocol models use `msgspec.Struct` — not dataclasses, not Pydantic. Optional fields use `msgspec.field(default=UNSET)` / `default=UNSET` with type `T | Unset`.
|
||||
@@ -81,10 +85,10 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
||||
- **`messages_bridge` / `messages_dispatch`**: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments; `thinking` from the request's `reasoning_effort` (level → token budget, floor 1024, `max_tokens` grown when the budget would leave no room) plus the client's configured `thinking_budget_tokens`; a thinking block the model refuses (from Claude 4.7 on, `thinking.type.enabled` is a 400 asking for adaptive thinking + `output_config.effort`; a model whose thinking is always on refuses `thinking.type.disabled`) gets one retry with the shape it takes — adaptive thinking with the level as the effort, or no block at all — and the refusal is remembered per model on the client, so only its first request pays the round trip (only a refusal that arrives before the stream yields anything is retried; a later failure has already been seen); a server tool (`server_tool_use` + its `web_search_tool_result`) is the surface's own business — it streams `input_json_delta` fragments like a call but nothing local asked for it, so only a `tool_use` block becomes a tool call and nothing is executed; `thinking` and the two search blocks are kept as `AssistantChatMessage.provider_blocks` (the thinking text also lands in `reasoning_content`, so it streams as reasoning) and go back with the round, which is also why a round that kept only those blocks is not an empty completion; a chat-completions request drops the field (`compat.coerce_chat_messages`).
|
||||
- **Provider-side tools**: `provider_tools` on the OpenAI **and** DeepSeek presets (list of dicts; `[{type="web_search"}]` when omitted; `[]` disables) threaded via `ChatAgent`/`run_chat_loop` into Responses and Messages; never executed by `ToolRegistry`. The Messages surface declares its own server tool instead — a bare `web_search` becomes `{type="web_search_20250305", name="web_search"}` (the search then runs inside the response, and its `encrypted_content` results go back verbatim with the round — `AssistantChatMessage.provider_blocks`) — and a DeepSeek provider on a surface that ignores hosted tools (`config.routing.local_web_search`) gets the local `web_search` tool from `tools/net/search.py` instead.
|
||||
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name. Before running, `execute` answers a call that omits a required argument with the accepted names (`_missing_required_error`) — the handler's bare `TypeError` never reaches the model, and no confirm prompt fires for a call that cannot run. Shape mistakes inside a *present* argument are the tool's own job (see the `chat` bullet). `@tool(writes=("path",))` names a handler's file arguments: one batch applies calls that name the same file one at a time, in call order — the lock is taken before the confirm prompt, so a pending prompt cannot be overtaken — while different files keep running in parallel.
|
||||
- **`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).
|
||||
- **`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 **or** after committed tools — does not re-run those tools).
|
||||
- **`/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.
|
||||
- Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
|
||||
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`; resolved `reasoning_effort` on every request (see Config). A round the provider cut off at the output limit is kept, not failed: the answer and its thinking blocks stay in `messages`, the round's tool calls are dropped (a half-written call must not run — the model re-issues it), and up to `max_truncation_continues` (2) further rounds carry on from a truncation notice; 0 spends the turn as unfinished instead, for `/retry`.
|
||||
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `provider_tools`; `retry()` continues incomplete turns (user-only, after committed tools — does not re-run those tools — or after a kept truncated answer) and `pending_retry_text` reads a truncation notice as unfinished work.
|
||||
- **`/compact`**: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first. The default summarizer system prompt states that tool calls are unavailable (a `compact_system_prompt` override replaces that default wholesale).
|
||||
- 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.
|
||||
|
||||
@@ -98,15 +102,15 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
||||
- **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_batch` (serial steps, `pipe_out` on provider, `mix_stderr`, `stop_on_error`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (escapes) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
|
||||
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`\xHH`, `ctrl+x`, `key=esc`); secrets via `ask_into_pty` (not `write_pty` data); session limit/idle TTL/output budget; close terminate→kill.
|
||||
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget. The prompt accepts `y`/empty (once), `n`, or `yyy` = stop asking for the rest of the turn (`cli.limits` module flag, reset by `run_turn_with_retries`, `/btw`, session switches, and chat exit). `--auto-continue` / `[agent] auto_continue_limits` set the process-wide default (covers one-shot/PTY); the flag does not imply YOLO and vice versa.
|
||||
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for interpreters — bare shells/REPLs, scripts, and `-c` one-liners alike (`-c` is code only for an interpreter, so `grep -c` / `od -c` stay unflagged; argv + code preview) —, detached runners (`nohup`), and wrapper options that carry a command line (`env -S "…"`, `su -c "…"`, whose text is printed under its own `env -S:` label), each resolved past env wrappers and `<launcher> run`; deny may include a user comment for the model. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn).
|
||||
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for interpreters — bare shells/REPLs, scripts, and `-c` one-liners alike (`-c` is code only for an interpreter, so `grep -c` / `od -c` stay unflagged; argv + code preview) —, detached runners (`nohup`), and wrapper options that carry a command line (`env -S "…"`, `su -c "…"`, whose text is printed under its own `env -S:` label), each resolved past env wrappers and `<launcher> run`; deny may include a user comment for the model. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn). A round of tool calls asks **once**: `ToolRegistry.prepare_batch_confirm` pre-scans it (unknown tools, undecodable arguments, and calls missing a required argument are left out — nobody is asked about a call that cannot run), two or more soft reasons collapse into one `ToolBatchConfirmHook` prompt whose `BatchConfirmDecision` `execute(..., decision=…)` applies to each call (CLI box lists every call: `y` = allow these, `m` = review them one at a time through the per-call prompt, `N` = deny with one comment shared by all), the answer covers that round only (the next one asks again), and a lone request keeps the plain one-call prompt.
|
||||
- **`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`**: HTTP 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.
|
||||
- **`net` / `web_search`** (`tools/net/search.py`, `tools/net/bing.py`): the hosted search a DeepSeek provider cannot use, 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 `/messages` turn with the server tool, `lmproto/deepseek/anthropic/search.py`) and/or `BingSearchBackend` (Bing's RSS results, snippets included, fetched through the same client as `fetch`). The handler returns one `title — url` entry per hit (plus the snippet when the source has one); 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 whose `provider_tools` ask for `web_search`, on a surface that ignores hosted tools (`convention` other than `anthropic`), with a non-empty `search_sources`. Tags LOCAL|READ_ONLY.
|
||||
- **`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` / `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`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `get_truncated`, `listdir`, `glob_paths`, `regex_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `skill_list`/`skill_read`/`skill_search`, `fetch`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch` 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`, `get_truncated`, `listdir`, `glob_paths`, `regex_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `skill_list`/`skill_read`/`skill_search`, `fetch`, `fetch_local`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch_local` accept GET only.
|
||||
|
||||
### Prompting (`prompting.py`)
|
||||
|
||||
@@ -121,7 +125,7 @@ Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
|
||||
- `/btw` side questions: forked session; main transcript/DB untouched. `--tools=read` (default) exposes only `READ_ONLY` tools under `read_only_context()` (fetch=GET) with the main session view; `--tools=no` off; `--tools=full` whole-registry clone with fresh session. Tools off: default falls back to no tools; explicit `read`/`full` errors. The model gets an `aside` notice as its last message before the question (unsaved exchange + tool scope), shown to the user as `[notice] aside: …`.
|
||||
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**. A session resumed by a **freshly started process** (`--session` / default latest) also gets a `resume` notice about what a restart drops (PTY sessions, running commands, temp workspaces) — resuming inside a live process pushes nothing.
|
||||
- Host notices (`agent/notices.py`): system-level messages appended as trailing `developer` rows, never system-prompt edits — `Notice.to_message()` for the model, `.summary` for the user via `cli.display.echo_notice` (`--quiet` hides the user line only). Durable hosts persist them (`ChatAgent.push_notice`); a side agent without memory keeps its notice local. `has_notice()` dedupes a session-scoped notice: the `skills` one is pushed once per session (feature enabled + tools on), lists nothing, and makes the model ask with `skill_list` / `skill_search`. Trailing developer messages never end a turn, so `/retry` still sees an interrupted turn behind a notice or checkpoint.
|
||||
- Failed/cancelled turns: user kept; **committed tool rounds kept**, and a batch cut short keeps the calls that already produced a result (`rollback_tail`; written to the store, so a resume continues from them instead of re-running them) — side effects are not re-run on `/retry`, and only unfinished assistant text is dropped; Ctrl+C cancels the in-flight turn (a session-scoped asyncio SIGINT handler routes it, `cli/interrupt.py` — a stray Ctrl+C while the loop waits never tears the REPL down); TTY confirms/ask prompts run off-loop and register their own SIGINT target, so Ctrl+C there cancels just the poll-based read (`wait` reports `cancelled by user`) or is ignored with a hint for readline prompts (`ask_user_*`, confirms) — never the turn, never the loop; auto-retry waits 5s/10s/15s/20s then +10s (10 attempts), its countdown is Ctrl+C-cancellable, and the budget **resets when an attempt completes a model round** (connection recovered); then `/retry`.
|
||||
- Failed/cancelled turns: user kept; **committed tool rounds kept**, and a batch cut short keeps the calls that already produced a result (`rollback_tail`; written to the store, so a resume continues from them instead of re-running them) — side effects are not re-run on `/retry`, and only unfinished assistant text is dropped; a round cut off at the output limit is not a failure at all: it is kept under a `truncated` notice (see the `run_chat_loop` bullet), which also keeps the turn unfinished for `/retry`; Ctrl+C cancels the in-flight turn (a session-scoped asyncio SIGINT handler routes it, `cli/interrupt.py` — a stray Ctrl+C while the loop waits never tears the REPL down); TTY confirms/ask prompts run off-loop and register their own SIGINT target, so Ctrl+C there cancels just the poll-based read (`wait` reports `cancelled by user`) or is ignored with a hint for readline prompts (`ask_user_*`, confirms) — never the turn, never the loop; auto-retry waits 5s/10s/15s/20s then +10s (10 attempts), its countdown is Ctrl+C-cancellable, and the budget **resets when an attempt completes a model round** (connection recovered); then `/retry`.
|
||||
- Pretty tool lines: every builtin tool prints its `* Verb 'target' ` prefix as the call starts and appends the outcome (`L1-4 (done)`, `(exit code 0)`, `(session 3)`) when the result lands, so a blocking tool stays visible while it runs; namespaced MCP tools share one generic line (`* MCP <server>:<tool> (done)`). Plugin tools and custom tool calls keep the `[tool] …` / `[tool ok] …` whole-line style. Interactive TTY only (`interactive_terminal()`): piped/plain output and `--verbose` keep whole lines. A second call of the same round holds its prefix back until the open row takes its own detail (results arrive in call order), so each call still prints one finished row; a prompt box (or other output) printed while the tool runs *ends* the open prefix as its own line and falls back to whole lines for the rest of the batch — the cursor is never moved, so a prefix wider than the terminal cannot be half-erased. A batch (one round's calls) that only touches todo tools prints one `* Todo x<N>: Push, Update` line plus its last result, whose text already carries the final stack, instead of one header and a full list render per call. `run_argv_batch` lists its commands above its row: one `$ command` line per step, a `pipe_out` chain joined into one line with ` | `, each line elided past 80 characters.
|
||||
- **`plyngent providers`**, **`config path|edit`**. Config open: `$VISUAL`/`$EDITOR` (wait), else system default (`xdg-open` / `open` / `startfile`, non-blocking). `/edit` stays blocking-only. No providers → optional edit then reload when waited.
|
||||
- Tools default on; workspace defaults to cwd; `--max-rounds` default 32. Readline history under platformdirs (`repl_history`). PTY: `close_all` on chat exit.
|
||||
|
||||
@@ -224,6 +224,14 @@ agent's Anthropic Messages path (tools, streaming, usage). DeepSeek maps
|
||||
DeepSeek ids pass through as-is. `GET /models` is not documented on that
|
||||
base, so model selection is config-driven.
|
||||
|
||||
On the Chat Completions and Responses conventions DeepSeek ignores a hosted
|
||||
search tool, so plyngent offers its own `web_search` tool instead. It queries
|
||||
DeepSeek's Search API (`POST /v1/tools/search`, one request per search, billed
|
||||
per successful request, 5 QPS account-wide) and can fall back to Bing's RSS
|
||||
(`search_sources = ["deepseek", "bing"]`) when that fails or finds nothing.
|
||||
`search_sources` sets the source order (`[]` disables the tool); `provider_tools
|
||||
= []` on the Anthropic convention switches that surface to the same local tool.
|
||||
|
||||
If `[database]` is omitted (or SQLite `url` is unset/empty), chat uses a durable file under the user data dir (e.g. `~/.local/share/plyngent/chat.db` on Linux). Set `url = ":memory:"` for a true in-memory SQLite (CLI warns; no file; useful for tests).
|
||||
|
||||
## Chat
|
||||
@@ -294,7 +302,7 @@ Type `/help` in the REPL for the live list. Common ones:
|
||||
| `/stream` `/verbose` `/markdown` `/tools` `/rounds` | Toggles and limits |
|
||||
| `/yolo [on\|off\|once]` | Soft destructive confirms: sticky skip, off, or next turn only |
|
||||
|
||||
| `/retry` | Re-run incomplete last user turn (after error/cancel) |
|
||||
| `/retry` | Retry/continue the incomplete last user turn (after error, cancel, or output limit) |
|
||||
| `/btw [--tools read\|no\|full] [--fresh]` | Side question without changing the main session (read-only tools by default; the model is told the exchange is unsaved and how its tools are scoped) |
|
||||
| `/provider` `/model` | Switch without restarting |
|
||||
| `/model --persist` | Save current model id into `plyngent.toml` catalog |
|
||||
@@ -306,7 +314,7 @@ Type `/help` in the REPL for the live list. Common ones:
|
||||
| `/config` | Edit `plyngent.toml` ($VISUAL/$EDITOR or system open); reload after blocking editor |
|
||||
| `/quit` | Leave the REPL |
|
||||
|
||||
User messages are saved immediately. On API error or Ctrl+C, unfinished assistant text is discarded — tool calls that already produced a result stay, so `/retry` does not run them again — but the user message stays so `/retry` works after resume. Interactive auto-retry waits 5s, 10s, 15s, 20s, then +10s each step (10 attempts); an attempt that gets a full model round back **resets that budget**, so a connection that recovers and drops again starts over from 5s. Tool calls of one round run in parallel, but writes to the same file apply one at a time, in call order — a later edit sees the earlier one's result instead of stale text.
|
||||
User messages are saved immediately. On API error or Ctrl+C, unfinished assistant text is discarded — tool calls that already produced a result stay, so `/retry` does not run them again — but the user message stays so `/retry` works after resume. Interactive auto-retry waits 5s, 10s, 15s, 20s, then +10s each step (10 attempts); an attempt that gets a full model round back **resets that budget**, so a connection that recovers and drops again starts over from 5s. A round cut off at the output limit (`max_tokens`) is not an error: what the model had written — thinking blocks included — stays in the transcript, its tool calls are dropped (they are re-issued, not replayed), and a notice tells it where it stopped, so up to 2 further rounds carry on from there; when that budget is spent the turn ends with the partial answer kept and `/retry` continues it. Tool calls of one round run in parallel, but writes to the same file apply one at a time, in call order — a later edit sees the earlier one's result instead of stale text.
|
||||
|
||||
Ctrl+C cancels the in-flight turn, and during the auto-retry countdown it cancels the retry. Model-initiated prompts run off the event loop: a `wait` prompt is cancelled by Ctrl+C (the tool reports `cancelled by user` and the turn continues), while `ask_user_*` and confirm prompts ignore it with a one-line hint — answer them to continue. Ctrl+C never exits the REPL (use Ctrl+D or `/quit`), and MCP servers run in their own process group, so it never kills them either.
|
||||
|
||||
@@ -319,7 +327,7 @@ Ctrl+C cancels the in-flight turn, and during the auto-retry countdown it cancel
|
||||
|
||||
## 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` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO), 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 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`).
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -37,4 +37,6 @@
|
||||
- web: Web service (Phase H; not implemented).
|
||||
|
||||
Plugins (third-party entry points, allowlisted under ``[plugins]``): [plugins.md](./plugins.md).
|
||||
Conversation units (a *round* = one request→response plus its tool batch, a
|
||||
*turn* = a user message to the explicit stop): [turns-and-rounds.md](./turns-and-rounds.md).
|
||||
|
||||
|
||||
@@ -192,6 +192,15 @@ access_key_or_token = "sk-replace-me"
|
||||
# # Thinking strength for this provider; per-model entries below may override it.
|
||||
# # reasoning_effort = "high"
|
||||
#
|
||||
# # Local ``web_search`` sources, in fallback order (``[]`` disables the tool).
|
||||
# # "deepseek" is the platform Search API (POST /v1/tools/search; billed per
|
||||
# # successful request, 5 QPS). "bing" is a brittle RSS backstop — opt in with
|
||||
# # ["deepseek", "bing"] when you want a fallback.
|
||||
# # ``provider_tools`` controls only the hosted search, these sources control
|
||||
# # plyngent's own tool: on the Anthropic convention ``provider_tools = []``
|
||||
# # leaves you the local Search API tool instead of the hosted one.
|
||||
# search_sources = ["deepseek"]
|
||||
#
|
||||
# [providers.deepseek.models]
|
||||
# "deepseek-flash" = { text = true, convention = "responses" }
|
||||
# "deepseek-v4-pro" = { text = true, reasoning_effort = "max" }
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Turns and Rounds
|
||||
|
||||
Two nested units of work carry most of the loop's state. A **round** is one
|
||||
request→response exchange with the provider plus the tool calls it asked for; a
|
||||
**turn** is everything between a user message and the explicit stop that ends it.
|
||||
A session is a stored sequence of turns.
|
||||
|
||||
```
|
||||
turn ─┬─ round ─┬─ request (hosted tools, history, system prompt)
|
||||
│ ├─ response (one assistant message)
|
||||
│ └─ tool batch (0..n calls, executed before the next round)
|
||||
├─ round …
|
||||
└─ explicit stop (a round with no tool calls)
|
||||
```
|
||||
|
||||
## Round — one model round trip
|
||||
|
||||
A round is one completion request and the single assistant message it produced,
|
||||
followed by the tool calls that message asked for (one batch). The next round
|
||||
starts only after that batch has reported results.
|
||||
|
||||
- **Identity.** One `UsageEvent` per round: `ChatAgent` counts them into
|
||||
`last_turn_rounds` (`agent/chat.py`). `AssistantMessageEvent` carries the
|
||||
response, `ToolCallEvent`/`ToolResultEvent` the batch. The transcript's
|
||||
`rounds=N` label counts the rounds that asked for tools (every round but the
|
||||
final one), so `/history` and `last_turn_rounds` can differ by that stop round.
|
||||
- **Ends.** In one of four ways: with tool calls (the loop continues), with no
|
||||
tool calls (natural stop → the turn ends), at the output limit
|
||||
(`TruncatedEvent`, `finish_reason=length`: answer and thinking stay, that
|
||||
round's tool calls are dropped and a notice is appended; up to
|
||||
`max_truncation_continues` more rounds of the same turn carry on from there),
|
||||
or in an error (a retry re-runs the round).
|
||||
- **Bounds.** `max_rounds` per turn (default `DEFAULT_MAX_ROUNDS = 32`,
|
||||
`agent/loop.py`). Reaching the allowance yields `MaxRoundsEvent(rounds,
|
||||
continued=False)` — `rounds` is the allowance that ran out — unless the
|
||||
`on_limit` hook answers yes (CLI: `y`/`n`/`yyy` prompt in `cli/limits.py`):
|
||||
then the event says `continued=True` and another `max_rounds` are granted.
|
||||
- **Parallelism.** Calls of one round run concurrently (`parallel_tools`), but
|
||||
calls naming the same file serialize through per-file write locks, and the
|
||||
soft confirm for a round is decided once, before the batch runs
|
||||
(`ToolRegistry.prepare_batch_confirm`).
|
||||
- **Round-scoped work.** Request assembly, context reduction / tool-result
|
||||
truncation over `max_context_tokens`, provider-tool (hosted search) blocks,
|
||||
and the usage sample are all per round.
|
||||
- **Not durable on its own.** Only committed prefixes are persisted as they
|
||||
happen — a complete tool batch with its results, plus trailing developer
|
||||
notices. A final text answer is committed at turn end, so a turn that dies
|
||||
mid-round leaves its side effects in history but not a half-written answer.
|
||||
|
||||
## Turn — one user message to the explicit stop
|
||||
|
||||
A turn begins when the user message is appended (`ChatAgent.run`, which also
|
||||
persists the message immediately) and ends at the explicit stop.
|
||||
|
||||
- **Explicit stop.** The first round whose assistant message has no tool calls.
|
||||
A turn also ends at a boundary: `max_rounds` without continuing,
|
||||
truncation-continues exhausted (left for `/retry`), cancellation (Ctrl+C), or
|
||||
an unrecovered error — the tail is rolled back to the committed prefix and the
|
||||
user message stays so `/retry` can continue.
|
||||
- **Rounds per turn.** Zero (a failure before the first response) to many.
|
||||
`retry` continues the *same* turn — it reuses the user message instead of
|
||||
appending a new one — so retries never create another turn.
|
||||
- **Turn-scoped state** (reset at the start of the next turn):
|
||||
- read-before-edit: line-numbered `read_file` authorizes `edit_lineno` for that
|
||||
path (mtime-checked) for this turn only (`reset_lineno_tracker`);
|
||||
- `yyy` limit auto-continue: cleared before and after
|
||||
`run_turn_with_retries`, so a retry of the same turn keeps it and the next
|
||||
user turn prompts again;
|
||||
- `/yolo once`: expires when the turn finishes (`expire_yolo_once`), so it
|
||||
covers exactly one turn;
|
||||
- todo nagging: `todo_stack.begin_turn()` clears the "touched" flag; any
|
||||
`todo_*` tool sets it again, and `needs_review()` at the stop decides whether
|
||||
a stack untouched this turn gets the review reminder;
|
||||
- usage totals: `last_turn_usage` and `last_turn_rounds` sum the turn's rounds
|
||||
(`last_request_usage` keeps the last round's sample).
|
||||
- **Not turn-scoped.** Session identity and stored rows, the todo stack's
|
||||
contents, soft-confirm trust (path grants, access/skill grants, `TRUSTABLE`
|
||||
tool grants), and directive-reminder bands outlive the turn.
|
||||
|
||||
## Session
|
||||
|
||||
A session is the stored sequence of turns; the transcript groups rows by turn,
|
||||
and `/history N` addresses a turn by number. Compaction rewrites the history sent
|
||||
in one request and never moves a turn boundary. A side turn (`run_aside`) is a
|
||||
turn whose history is forked and never written back.
|
||||
|
||||
## Where it lives
|
||||
|
||||
| unit | begins | ends | counters / records |
|
||||
| --- | --- | --- | --- |
|
||||
| round | provider request | response assembled, tool batch done | `UsageEvent`, `last_turn_rounds`, `MaxRoundsEvent`, `TruncatedEvent` |
|
||||
| turn | `ChatAgent.run` appends the user message | a round with no tool calls (or a boundary) | session rows, `last_turn_usage` |
|
||||
| session | first turn | process exit / session switch | store tables, transcript |
|
||||
|
||||
Loop mechanics: `agent/loop.py` (`run_chat_loop`); turn bookkeeping:
|
||||
`agent/chat.py` (`ChatAgent.run` / `retry` / `_run_from_user_message`); retry and
|
||||
turn-scoped flags: `cli/retry.py`, `cli/limits.py`.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "plyngent"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
description = "LLM chat and agent toolkit"
|
||||
authors = [
|
||||
{ name = "HivertMoZara", email = "worldmozara@163.com" },
|
||||
|
||||
@@ -17,6 +17,7 @@ from .events import ReasoningDeltaEvent as ReasoningDeltaEvent
|
||||
from .events import TextDeltaEvent as TextDeltaEvent
|
||||
from .events import ToolCallEvent as ToolCallEvent
|
||||
from .events import ToolResultEvent as ToolResultEvent
|
||||
from .events import TruncatedEvent as TruncatedEvent
|
||||
from .events import UsageEvent as UsageEvent
|
||||
from .loop import DEFAULT_MAX_ROUNDS as DEFAULT_MAX_ROUNDS
|
||||
from .loop import run_chat_loop as run_chat_loop
|
||||
@@ -26,8 +27,12 @@ from .notices import format_moment as format_moment
|
||||
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 BatchConfirmDecision as BatchConfirmDecision
|
||||
from .tools import DangerClassifier as DangerClassifier
|
||||
from .tools import ToolBatchConfirmHook as ToolBatchConfirmHook
|
||||
from .tools import ToolConfirmHook as ToolConfirmHook
|
||||
from .tools import ToolConfirmRequest as ToolConfirmRequest
|
||||
from .tools import ToolDefinition as ToolDefinition
|
||||
from .tools import ToolRegistry as ToolRegistry
|
||||
from .tools import ToolTag as ToolTag
|
||||
|
||||
@@ -27,7 +27,8 @@ from .directive_checkpoint import (
|
||||
parse_checkpoint_bands,
|
||||
)
|
||||
from .events import UsageEvent
|
||||
from .loop import DEFAULT_MAX_ROUNDS, run_chat_loop
|
||||
from .loop import DEFAULT_MAX_ROUNDS, DEFAULT_MAX_TRUNCATION_CONTINUES, run_chat_loop
|
||||
from .notices import TRUNCATED_NOTICE_KIND, notice_of_kind
|
||||
from .todo_nag import (
|
||||
DEFAULT_TODO_NAG_STRATEGY,
|
||||
parse_todo_nag_strategy,
|
||||
@@ -140,8 +141,10 @@ def incomplete_turn_user_text(messages: Sequence[AnyChatMessage]) -> str | None:
|
||||
"""User text for an incomplete turn, if history can be retried.
|
||||
|
||||
Incomplete when the last non-system message is a user message (failed before
|
||||
any commit), or tool results after a complete tool batch (failed on a later
|
||||
model round — keep tools so retry does not re-execute side effects).
|
||||
any commit), tool results after a complete tool batch (failed on a later
|
||||
model round — keep tools so retry does not re-execute side effects), or a
|
||||
partial answer under a truncation notice (the round hit the output limit; its
|
||||
answer was kept, so retry continues it instead of regenerating).
|
||||
Trailing developer messages (host notices, directive checkpoints) belong to
|
||||
the turn but never end it, so they are skipped when reading the tail.
|
||||
"""
|
||||
@@ -150,14 +153,17 @@ def incomplete_turn_user_text(messages: Sequence[AnyChatMessage]) -> str | None:
|
||||
if messages and isinstance(messages[0], SystemChatMessage):
|
||||
start = 1
|
||||
body = list(messages[start:])
|
||||
truncated = False
|
||||
while body and isinstance(body[-1], DeveloperChatMessage):
|
||||
if notice_of_kind(body[-1], TRUNCATED_NOTICE_KIND):
|
||||
truncated = True
|
||||
_ = body.pop()
|
||||
if not body:
|
||||
return None
|
||||
last = body[-1]
|
||||
if isinstance(last, UserChatMessage):
|
||||
return last.content
|
||||
if isinstance(last, ToolChatMessage):
|
||||
if isinstance(last, ToolChatMessage) or (truncated and isinstance(last, AssistantChatMessage)):
|
||||
# Walk back to the user that opened this turn.
|
||||
for msg in reversed(body):
|
||||
if isinstance(msg, UserChatMessage):
|
||||
@@ -289,6 +295,8 @@ class ChatAgent:
|
||||
memory: MemoryStore | None
|
||||
session_id: int | None
|
||||
max_rounds: int
|
||||
# Rounds a turn may carry on after hitting the output limit (0 = none).
|
||||
max_truncation_continues: int
|
||||
temperature: float | None
|
||||
# Resolved thinking strength ("" = provider default); see config.reasoning.
|
||||
reasoning_effort: str
|
||||
@@ -322,6 +330,7 @@ class ChatAgent:
|
||||
memory: MemoryStore | None = None,
|
||||
session_id: int | None = None,
|
||||
max_rounds: int = DEFAULT_MAX_ROUNDS,
|
||||
max_truncation_continues: int = DEFAULT_MAX_TRUNCATION_CONTINUES,
|
||||
temperature: float | None = None,
|
||||
reasoning_effort: str = "",
|
||||
messages: Sequence[AnyChatMessage] | None = None,
|
||||
@@ -345,6 +354,7 @@ class ChatAgent:
|
||||
self.memory = memory
|
||||
self.session_id = session_id
|
||||
self.max_rounds = max_rounds
|
||||
self.max_truncation_continues = max(0, int(max_truncation_continues))
|
||||
self.temperature = temperature
|
||||
self.reasoning_effort = reasoning_effort
|
||||
self.on_limit = on_limit
|
||||
@@ -603,6 +613,7 @@ class ChatAgent:
|
||||
model=self.model,
|
||||
tools=self.tools,
|
||||
max_rounds=self.max_rounds,
|
||||
max_truncation_continues=self.max_truncation_continues,
|
||||
temperature=self.temperature,
|
||||
reasoning_effort=self.reasoning_effort,
|
||||
on_limit=self.on_limit,
|
||||
@@ -747,6 +758,7 @@ class ChatAgent:
|
||||
memory=None,
|
||||
session_id=None,
|
||||
max_rounds=rounds,
|
||||
max_truncation_continues=self.max_truncation_continues,
|
||||
temperature=self.temperature,
|
||||
reasoning_effort=self.reasoning_effort,
|
||||
on_limit=self.on_limit,
|
||||
|
||||
@@ -30,6 +30,8 @@ _SUMMARY_SYSTEM = (
|
||||
"Write a dense, factual summary that preserves: goals, decisions, "
|
||||
"file paths touched, commands run, open tasks, and constraints. "
|
||||
"Omit chit-chat and redundant tool dumps. Use short bullet sections. "
|
||||
"Tool calls are unavailable in this request: do not call tools or emit "
|
||||
"tool-call syntax; summarize only what the transcript contains. "
|
||||
"Do not invent facts not present in the transcript."
|
||||
)
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ use `run_argv_batch` for ordered pipelines.
|
||||
- `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`.
|
||||
- Prefer `fetch` for HTTP(S); `offset` (0-based) skips leading body characters; \
|
||||
private/LAN hosts need human policy allow (not YOLO).
|
||||
- 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.
|
||||
- `wait` pauses; pressing Enter disturbs early (optional reason).
|
||||
- Todo stack: open items mean unfinished work; the stack does not auto-clear — \
|
||||
|
||||
@@ -34,6 +34,19 @@ class MaxRoundsEvent(Struct, tag_field="type", tag="max_rounds"):
|
||||
continued: bool = False
|
||||
|
||||
|
||||
class TruncatedEvent(Struct, tag_field="type", tag="truncated"):
|
||||
"""A round the provider cut off at the output limit (``finish_reason=length``).
|
||||
|
||||
The partial answer stays in history rather than being discarded, and the
|
||||
model is told where it stopped (see ``notices.truncated_turn_notice``);
|
||||
``continued`` says whether the turn went on with another round or is waiting
|
||||
for ``/retry``.
|
||||
"""
|
||||
|
||||
kept_chars: int = 0
|
||||
continued: bool = False
|
||||
|
||||
|
||||
class ErrorEvent(Struct, tag_field="type", tag="error"):
|
||||
message: str
|
||||
retryable: bool = True
|
||||
@@ -60,6 +73,7 @@ type AgentEvent = (
|
||||
| ToolCallEvent
|
||||
| ToolResultEvent
|
||||
| MaxRoundsEvent
|
||||
| TruncatedEvent
|
||||
| ErrorEvent
|
||||
| CancelledEvent
|
||||
| UsageEvent
|
||||
|
||||
+105
-18
@@ -43,8 +43,11 @@ from .events import (
|
||||
TextDeltaEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
TruncatedEvent,
|
||||
UsageEvent,
|
||||
)
|
||||
from .finish_reason import chat_finish_reason
|
||||
from .notices import truncated_turn_notice
|
||||
from .todo_nag import (
|
||||
DEFAULT_TODO_NAG_STRATEGY,
|
||||
inject_todo_nag_for_stack_with_events,
|
||||
@@ -61,13 +64,17 @@ if TYPE_CHECKING:
|
||||
|
||||
from .todo_nag import TodoNagStrategy
|
||||
from .todo_stack import TodoStack
|
||||
from .tools import ToolRegistry
|
||||
from .tools import BatchConfirmDecision, ToolRegistry
|
||||
from .types import AnyLLMClient
|
||||
|
||||
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
|
||||
|
||||
|
||||
DEFAULT_MAX_ROUNDS = 32
|
||||
# Rounds cut off at the output limit are kept and the model carries on in another
|
||||
# round, this many times per turn; 0 stops after the truncated round (`/retry`
|
||||
# continues it from the kept partial answer).
|
||||
DEFAULT_MAX_TRUNCATION_CONTINUES = 2
|
||||
|
||||
|
||||
async def _call_on_limit(on_limit: LimitContinueHook, reason: str) -> bool:
|
||||
@@ -82,10 +89,11 @@ async def _run_one_tool(
|
||||
call: AnyAssistantToolCall,
|
||||
*,
|
||||
max_result_chars: int,
|
||||
decision: BatchConfirmDecision | None = None,
|
||||
) -> tuple[ToolChatMessage, ErrorEvent | None]:
|
||||
if isinstance(call, AssistantFunctionToolCall):
|
||||
try:
|
||||
result_text = await tools.execute(call.function.name, call.function.arguments)
|
||||
result_text = await tools.execute(call.function.name, call.function.arguments, decision=decision)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@@ -117,12 +125,31 @@ async def _execute_tool_calls(
|
||||
for call in tool_calls:
|
||||
yield ToolCallEvent(tool_call=call)
|
||||
|
||||
# One confirm prompt for the round's mutating calls, so a batch of moves or
|
||||
# deletes asks once instead of once per call. Calls that need no confirm (or
|
||||
# cannot run) keep ``None`` and go through their own path below.
|
||||
requests: list[tuple[str, str]] = []
|
||||
positions: list[int] = []
|
||||
for index, call in enumerate(tool_calls):
|
||||
if isinstance(call, AssistantFunctionToolCall):
|
||||
requests.append((call.function.name, call.function.arguments))
|
||||
positions.append(index)
|
||||
decisions: list[BatchConfirmDecision | None] = [None] * len(tool_calls)
|
||||
for index, decision in zip(positions, await tools.prepare_batch_confirm(requests), strict=True):
|
||||
decisions[index] = decision
|
||||
|
||||
if parallel and len(tool_calls) > 1:
|
||||
results = await asyncio.gather(
|
||||
*[_run_one_tool(tools, call, max_result_chars=max_result_chars) for call in tool_calls]
|
||||
*[
|
||||
_run_one_tool(tools, call, max_result_chars=max_result_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) for call in tool_calls]
|
||||
results = [
|
||||
await _run_one_tool(tools, call, max_result_chars=max_result_chars, decision=decision)
|
||||
for call, decision in zip(tool_calls, decisions, strict=True)
|
||||
]
|
||||
|
||||
# Record every result before yielding any: a caller that goes away mid-batch
|
||||
# (cancel, display error) must not lose work that already ran, or the retry
|
||||
@@ -150,6 +177,11 @@ def _assistant_has_payload(assistant: AssistantChatMessage) -> bool:
|
||||
return bool(isinstance(reasoning, str) and reasoning.strip())
|
||||
|
||||
|
||||
def _kept_chars(assistant: AssistantChatMessage) -> int:
|
||||
"""How much answer a cut-off round had written (0 if only reasoning/blocks)."""
|
||||
return len(assistant.content) if isinstance(assistant.content, str) else 0
|
||||
|
||||
|
||||
def _finish_reason_value(finish: object) -> str | None:
|
||||
if finish is UNSET or finish is None:
|
||||
return None
|
||||
@@ -163,20 +195,26 @@ def _validate_assistant_terminal(
|
||||
*,
|
||||
finish_reason: str | None,
|
||||
stream_terminal: bool,
|
||||
) -> None:
|
||||
"""Raise when the round is not a usable agent stop.
|
||||
) -> bool:
|
||||
"""Raise when the round is not a usable agent stop; True if it hit the output limit.
|
||||
|
||||
Distinguishes empty generation, truncated/filtered stops, and missing
|
||||
stream terminals (network/client glitch) from a normal stop/tool_calls end.
|
||||
Distinguishes empty generation, filtered stops, and missing stream terminals
|
||||
(network/client glitch) from a normal stop/tool_calls end. A round cut off at
|
||||
the output limit is not an error of its own: what it wrote is usable, so the
|
||||
caller keeps it and tells the model where it stopped. A truncated round that
|
||||
wrote nothing stays a failure — there is nothing to continue from.
|
||||
"""
|
||||
reason = (finish_reason or "").lower() or None
|
||||
canonical = chat_finish_reason(reason, has_tool_calls=False)
|
||||
|
||||
if reason in {"length", "content_filter"}:
|
||||
label = "truncated (max tokens)" if reason == "length" else "content filter"
|
||||
detail = ""
|
||||
if isinstance(assistant.content, str) and assistant.content.strip():
|
||||
detail = f"; partial text kept ({len(assistant.content)} chars)"
|
||||
msg = f"model stopped early: {label}{detail}"
|
||||
if canonical == "length":
|
||||
if _assistant_has_payload(assistant):
|
||||
return True
|
||||
msg = "model stopped early: output limit reached with nothing written"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
if canonical == "content_filter":
|
||||
msg = "model stopped early: content filter"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
if reason in {"failed", "incomplete", "cancelled"}:
|
||||
@@ -184,7 +222,7 @@ def _validate_assistant_terminal(
|
||||
raise RuntimeError(msg)
|
||||
|
||||
if _assistant_has_payload(assistant):
|
||||
return
|
||||
return False
|
||||
|
||||
if not stream_terminal and reason is None:
|
||||
msg = (
|
||||
@@ -249,7 +287,7 @@ async def _non_stream_round(
|
||||
choice = response.choices[0]
|
||||
assistant = choice.message
|
||||
finish = _finish_reason_value(choice.finish_reason)
|
||||
_validate_assistant_terminal(
|
||||
truncated = _validate_assistant_terminal(
|
||||
assistant,
|
||||
finish_reason=finish,
|
||||
stream_terminal=True,
|
||||
@@ -260,6 +298,8 @@ async def _non_stream_round(
|
||||
if isinstance(assistant.content, str) and assistant.content:
|
||||
yield TextDeltaEvent(content=assistant.content)
|
||||
yield AssistantMessageEvent(message=assistant)
|
||||
if truncated:
|
||||
yield TruncatedEvent(kept_chars=_kept_chars(assistant))
|
||||
yield UsageEvent(
|
||||
usage=resolve_round_usage(response.usage, param.messages, assistant),
|
||||
)
|
||||
@@ -376,12 +416,14 @@ async def _stream_round(
|
||||
# If we got payload but no finish_reason, treat as terminal (some providers
|
||||
# omit it); if empty and no finish_reason, flag as missing terminal.
|
||||
stream_terminal = saw_terminal or _assistant_has_payload(assistant)
|
||||
_validate_assistant_terminal(
|
||||
truncated = _validate_assistant_terminal(
|
||||
assistant,
|
||||
finish_reason=finish_reason,
|
||||
stream_terminal=stream_terminal,
|
||||
)
|
||||
yield AssistantMessageEvent(message=assistant)
|
||||
if truncated:
|
||||
yield TruncatedEvent(kept_chars=_kept_chars(assistant))
|
||||
yield UsageEvent(
|
||||
usage=resolve_round_usage(last_api_usage, param.messages, assistant),
|
||||
)
|
||||
@@ -408,6 +450,27 @@ async def _assistant_round(
|
||||
yield event
|
||||
|
||||
|
||||
def _keep_truncated_round(
|
||||
messages: list[AnyChatMessage],
|
||||
truncated: TruncatedEvent,
|
||||
*,
|
||||
continued: bool,
|
||||
) -> TruncatedEvent:
|
||||
"""Keep a round cut off at the output limit and tell the model where it stopped.
|
||||
|
||||
The answer and its thinking blocks stay in ``messages`` (the provider verifies
|
||||
those blocks, so dropping them would make the round uncontinuable), but the
|
||||
round's tool calls go: a half-written call must not run, its arguments may not
|
||||
even parse, and the model re-issues it after reading the notice. Returns the
|
||||
event to yield, with the continuation outcome filled in.
|
||||
"""
|
||||
last = messages[-1]
|
||||
if isinstance(last, AssistantChatMessage) and last.tool_calls is not UNSET and last.tool_calls:
|
||||
messages[-1] = msgspec.structs.replace(last, tool_calls=UNSET)
|
||||
messages.append(truncated_turn_notice().to_message())
|
||||
return msgspec.structs.replace(truncated, continued=continued)
|
||||
|
||||
|
||||
def _last_assistant(messages: list[AnyChatMessage], pre_len: int) -> AssistantChatMessage:
|
||||
if len(messages) <= pre_len:
|
||||
msg = "model round produced no assistant message"
|
||||
@@ -448,13 +511,14 @@ async def _maybe_inject_directive_checkpoints(
|
||||
return new_band
|
||||
|
||||
|
||||
async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
||||
async def run_chat_loop( # noqa: C901, PLR0915 — multi-phase tool loop
|
||||
client: AnyLLMClient,
|
||||
messages: list[AnyChatMessage],
|
||||
*,
|
||||
model: str,
|
||||
tools: ToolRegistry | None = None,
|
||||
max_rounds: int = DEFAULT_MAX_ROUNDS,
|
||||
max_truncation_continues: int = DEFAULT_MAX_TRUNCATION_CONTINUES,
|
||||
temperature: float | None = None,
|
||||
reasoning_effort: str = "",
|
||||
on_limit: LimitContinueHook | None = None,
|
||||
@@ -481,6 +545,12 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
||||
the provider's own default); each API surface maps it onto its own wire field
|
||||
(see ``config.reasoning``).
|
||||
|
||||
A round the provider cuts off at the output limit (``finish_reason=length``)
|
||||
is kept instead of failing the turn: its answer and thinking blocks stay in
|
||||
``messages``, its tool calls are dropped, and a truncation notice tells the
|
||||
model where it stopped. Up to *max_truncation_continues* rounds per turn then
|
||||
carry on from there (0 = stop, leaving the turn for ``/retry``).
|
||||
|
||||
*provider_tools* are hosted/provider-side tools (OpenAI Responses only, e.g.
|
||||
``web_search``) merged into the request as opaque dicts; never executed by the
|
||||
:class:`ToolRegistry`. Ignored by non-Responses clients.
|
||||
@@ -500,6 +570,7 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
||||
|
||||
rounds_used = 0
|
||||
allowance = max_rounds
|
||||
truncation_continues = 0
|
||||
# Calibrate soft-compact from last model call's prompt_tokens (API preferred).
|
||||
prompt_tokens_hint: int | None = None
|
||||
sent_estimate_tokens: int | None = None
|
||||
@@ -532,6 +603,7 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
||||
|
||||
pre_len = len(messages)
|
||||
last_usage_event: UsageEvent | None = None
|
||||
truncated: TruncatedEvent | None = None
|
||||
async for event in _assistant_round(
|
||||
client,
|
||||
param,
|
||||
@@ -544,9 +616,24 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
||||
prompt_tokens_hint = event.usage.prompt_tokens
|
||||
sent_estimate_tokens = sent_est
|
||||
last_usage_event = event
|
||||
if isinstance(event, TruncatedEvent):
|
||||
# Yielded below, once the continuation decision is known.
|
||||
truncated = event
|
||||
continue
|
||||
yield event
|
||||
|
||||
assistant = _last_assistant(messages, pre_len)
|
||||
if truncated is not None:
|
||||
# The provider cut the round off at the output limit: keep what it
|
||||
# wrote and tell the model where it stopped (see the helper).
|
||||
continued = truncation_continues < max_truncation_continues
|
||||
if continued:
|
||||
truncation_continues += 1
|
||||
yield _keep_truncated_round(messages, truncated, continued=continued)
|
||||
if continued:
|
||||
continue
|
||||
return
|
||||
|
||||
tool_calls = assistant.tool_calls
|
||||
has_tools = tool_calls is not UNSET and bool(tool_calls) and tools is not None
|
||||
if has_tools:
|
||||
|
||||
@@ -27,6 +27,11 @@ if TYPE_CHECKING:
|
||||
# developer messages (durable directive checkpoints share the role).
|
||||
NOTICE_MARKER = "[plyngent notice"
|
||||
|
||||
# Notice kind for a turn the provider cut off at the output limit. The turn's
|
||||
# partial answer is kept, so the agent reads such a turn as unfinished and
|
||||
# ``/retry`` continues it (see ``ChatAgent.pending_retry_text``).
|
||||
TRUNCATED_NOTICE_KIND = "truncated"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Notice:
|
||||
@@ -41,14 +46,18 @@ class Notice:
|
||||
return DeveloperChatMessage(content=f"{NOTICE_MARKER}: {self.kind}]\n{self.body.strip()}")
|
||||
|
||||
|
||||
def notice_of_kind(message: AnyChatMessage, kind: str) -> bool:
|
||||
"""Whether *message* is a notice of *kind*."""
|
||||
return isinstance(message, DeveloperChatMessage) and message.content.startswith(f"{NOTICE_MARKER}: {kind}]")
|
||||
|
||||
|
||||
def has_notice(messages: Sequence[AnyChatMessage], kind: str) -> bool:
|
||||
"""Whether *messages* already carries a notice of *kind*.
|
||||
|
||||
Notices are persisted with the transcript, so a host asks this before
|
||||
pushing one that belongs to the session rather than to an event.
|
||||
"""
|
||||
marker = f"{NOTICE_MARKER}: {kind}]"
|
||||
return any(isinstance(message, DeveloperChatMessage) and message.content.startswith(marker) for message in messages)
|
||||
return any(notice_of_kind(message, kind) for message in messages)
|
||||
|
||||
|
||||
def format_moment(moment: datetime | None) -> str:
|
||||
@@ -82,11 +91,32 @@ def resume_notice(*, last_active: datetime | None) -> Notice:
|
||||
return Notice(kind="resume", body=body, summary=summary)
|
||||
|
||||
|
||||
def truncated_turn_notice() -> Notice:
|
||||
"""Notice for a round the provider cut off at the output limit.
|
||||
|
||||
The partial answer is kept in the transcript (thinking blocks included, so
|
||||
the provider still verifies them), which is why the model has to be told the
|
||||
round was not finished: it continues from what it already wrote, re-issues
|
||||
the tool call that was dropped, and keeps the remainder short enough to fit.
|
||||
"""
|
||||
body = (
|
||||
"Your previous round was cut off at the output limit (max_tokens). The partial answer it wrote "
|
||||
"was kept in the transcript, and any tool call in that round was discarded — re-issue it if you "
|
||||
"still need it. Continue from where the answer stopped instead of writing it again from the "
|
||||
"start, and keep the rest short enough to finish: split the work, or hand off what is left."
|
||||
)
|
||||
return Notice(
|
||||
kind=TRUNCATED_NOTICE_KIND,
|
||||
body=body,
|
||||
summary="output truncated (max_tokens); partial answer kept",
|
||||
)
|
||||
|
||||
|
||||
_ASIDE_BODIES: dict[str, str] = {
|
||||
"read": (
|
||||
"Side question: this exchange is not saved to the session transcript and does not "
|
||||
"change the main conversation. Tools here are read-only (file/VCS reads, ask, fetch "
|
||||
"GET): you cannot edit files or run state-changing commands, so describe a change "
|
||||
"change the main conversation. Tools here are read-only (file/VCS reads, ask, fetch/"
|
||||
"fetch_local GET): you cannot edit files or run state-changing commands, so describe a change "
|
||||
"instead of applying it."
|
||||
),
|
||||
"no": (
|
||||
|
||||
+164
-14
@@ -3,8 +3,9 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import inspect
|
||||
import types
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from contextlib import AsyncExitStack
|
||||
from dataclasses import dataclass
|
||||
from enum import Flag, auto
|
||||
from pathlib import Path
|
||||
from typing import Any, cast, get_args, get_origin, get_type_hints, overload
|
||||
@@ -23,6 +24,39 @@ type ToolConfirmHook = Callable[
|
||||
ToolConfirmResult | Awaitable[ToolConfirmResult],
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ToolConfirmRequest:
|
||||
"""One call of a batch that needs a soft confirm before it may run."""
|
||||
|
||||
name: str
|
||||
args: Mapping[str, object]
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BatchConfirmDecision:
|
||||
"""Host answer for one round's soft-confirm requests.
|
||||
|
||||
It covers the calls of that round only; the next round asks again. With
|
||||
``each`` the host hands the calls back to the one-call prompt instead of
|
||||
answering for the batch (``allowed``/``comment`` then carry no meaning).
|
||||
"""
|
||||
|
||||
allowed: bool
|
||||
comment: str | None = None
|
||||
each: bool = False
|
||||
|
||||
|
||||
# One prompt for a round's requests; a single request keeps ``ToolConfirmHook``.
|
||||
type ToolBatchConfirmHook = Callable[
|
||||
[Sequence[ToolConfirmRequest]],
|
||||
BatchConfirmDecision | Awaitable[BatchConfirmDecision],
|
||||
]
|
||||
|
||||
MIN_BATCH_CONFIRM_REQUESTS = 2
|
||||
"""Below this many requests a round asks one call at a time (see ``prepare_batch_confirm``)."""
|
||||
|
||||
_PRIMITIVE_SCHEMA: dict[type, JSONSchema] = {
|
||||
str: {"type": "string"},
|
||||
int: {"type": "integer"},
|
||||
@@ -242,6 +276,24 @@ def _missing_required_error(definition: ToolDefinition, args: Mapping[str, objec
|
||||
return f"error: `{definition.name}` is missing required argument(s): {', '.join(missing)}{hint}"
|
||||
|
||||
|
||||
def _decode_tool_args(arguments_json: str) -> dict[str, object] | str:
|
||||
"""Decode one call's arguments; a returned string is the model-facing error."""
|
||||
try:
|
||||
raw_args: object = msgspec.json.decode(arguments_json.encode())
|
||||
except (msgspec.DecodeError, UnicodeEncodeError) as exc:
|
||||
return f"error: invalid tool arguments JSON: {exc}"
|
||||
if not isinstance(raw_args, dict):
|
||||
return "error: tool arguments must be a JSON object"
|
||||
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 _write_path_key(value: str) -> str:
|
||||
"""Comparable key for one path argument of a writing tool.
|
||||
|
||||
@@ -257,6 +309,7 @@ class ToolRegistry:
|
||||
_tools: dict[str, ToolDefinition]
|
||||
_danger: DangerClassifier | None
|
||||
_on_confirm: ToolConfirmHook | None
|
||||
_on_confirm_batch: ToolBatchConfirmHook | None
|
||||
_confirm_lock: asyncio.Lock
|
||||
# One lock per written file, kept for the registry's life (a session's edits
|
||||
# bound the map), so a batch's writes to one file apply one at a time.
|
||||
@@ -272,6 +325,7 @@ class ToolRegistry:
|
||||
*,
|
||||
danger: DangerClassifier | None = None,
|
||||
on_confirm: ToolConfirmHook | None = None,
|
||||
on_confirm_batch: ToolBatchConfirmHook | None = None,
|
||||
yolo: bool = False,
|
||||
auto_bind_state: bool = False,
|
||||
instance_state: object | None = None,
|
||||
@@ -280,6 +334,7 @@ class ToolRegistry:
|
||||
self._tools = {}
|
||||
self._danger = danger
|
||||
self._on_confirm = on_confirm
|
||||
self._on_confirm_batch = on_confirm_batch
|
||||
self._confirm_lock = asyncio.Lock()
|
||||
self._write_locks = {}
|
||||
self._yolo = yolo
|
||||
@@ -339,6 +394,7 @@ class ToolRegistry:
|
||||
definitions,
|
||||
danger=self._danger,
|
||||
on_confirm=self._on_confirm,
|
||||
on_confirm_batch=self._on_confirm_batch,
|
||||
yolo=self._yolo if yolo is None else yolo,
|
||||
auto_bind_state=self._auto_bind_state if auto_bind_state is None else auto_bind_state,
|
||||
instance_state=instance_state,
|
||||
@@ -463,9 +519,92 @@ class ToolRegistry:
|
||||
if decision is True:
|
||||
await self._record_grant(name, tags=tags)
|
||||
return None
|
||||
if isinstance(decision, str) and decision.strip():
|
||||
return f"error: tool {name!r} denied by user confirm ({reason}); user comment: {decision.strip()}"
|
||||
return f"error: tool {name!r} denied by user confirm ({reason})"
|
||||
comment = decision if isinstance(decision, str) else None
|
||||
return _denied_error(name, reason, comment)
|
||||
|
||||
async def _apply_batch_decision(
|
||||
self,
|
||||
name: str,
|
||||
reason: str,
|
||||
decision: BatchConfirmDecision,
|
||||
*,
|
||||
tags: ToolTag,
|
||||
) -> str | None:
|
||||
if decision.allowed:
|
||||
await self._record_grant(name, tags=tags)
|
||||
return None
|
||||
return _denied_error(name, reason, decision.comment)
|
||||
|
||||
def _prepare_confirm(self, name: str, arguments_json: str) -> ToolConfirmRequest | None:
|
||||
"""Soft-confirm request for one call, or ``None`` when it needs none.
|
||||
|
||||
Mirrors the front half of :meth:`execute`: a call whose arguments fail to
|
||||
decode or miss a required argument is answered with an error there and
|
||||
must not be put in front of the user now.
|
||||
"""
|
||||
definition = self._tools.get(name)
|
||||
if self._danger is None or definition is None:
|
||||
return None
|
||||
args = _decode_tool_args(arguments_json)
|
||||
if isinstance(args, str) or _missing_required_error(definition, args) is not None:
|
||||
return None
|
||||
reason = self._danger(name, args)
|
||||
return None if reason is None else ToolConfirmRequest(name=name, args=args, reason=reason)
|
||||
|
||||
def _batch_requests(
|
||||
self,
|
||||
calls: Sequence[tuple[str, str]],
|
||||
) -> tuple[list[ToolConfirmRequest], list[int]]:
|
||||
"""Requests for a round, plus the index each one came from."""
|
||||
requests: list[ToolConfirmRequest] = []
|
||||
positions: list[int] = []
|
||||
for index, (name, arguments_json) in enumerate(calls):
|
||||
request = self._prepare_confirm(name, arguments_json)
|
||||
if request is not None:
|
||||
requests.append(request)
|
||||
positions.append(index)
|
||||
return requests, positions
|
||||
|
||||
async def _record_batch_allow(
|
||||
self,
|
||||
requests: Sequence[ToolConfirmRequest],
|
||||
decision: BatchConfirmDecision,
|
||||
) -> None:
|
||||
"""Record the trust a batch allow carries (the trustable grants)."""
|
||||
if decision.allowed:
|
||||
for request in requests:
|
||||
definition = self._tools.get(request.name)
|
||||
if definition is not None:
|
||||
await self._record_grant(request.name, tags=definition.tags)
|
||||
|
||||
async def prepare_batch_confirm(
|
||||
self,
|
||||
calls: Sequence[tuple[str, str]],
|
||||
) -> list[BatchConfirmDecision | None]:
|
||||
"""Ask once for every soft confirm a round's calls would raise.
|
||||
|
||||
Returns one entry per call: a decision to apply instead of prompting, or
|
||||
``None`` to fall through to the per-call path. Fewer than
|
||||
:data:`MIN_BATCH_CONFIRM_REQUESTS` requests keep the plain one-call
|
||||
prompt, a host without a batch hook keeps the per-call prompts, and a
|
||||
:attr:`BatchConfirmDecision.each` answer hands this round back to them.
|
||||
"""
|
||||
decisions: list[BatchConfirmDecision | None] = [None] * len(calls)
|
||||
if self._danger is None or self._on_confirm_batch is None:
|
||||
return decisions
|
||||
requests, positions = self._batch_requests(calls)
|
||||
if len(requests) < MIN_BATCH_CONFIRM_REQUESTS:
|
||||
return decisions
|
||||
async with self._confirm_lock:
|
||||
decision = self._on_confirm_batch(requests)
|
||||
if inspect.isawaitable(decision):
|
||||
decision = await decision
|
||||
if decision.each:
|
||||
return decisions
|
||||
await self._record_batch_allow(requests, decision)
|
||||
for index in positions:
|
||||
decisions[index] = decision
|
||||
return decisions
|
||||
|
||||
async def _maybe_confirm(
|
||||
self,
|
||||
@@ -473,12 +612,14 @@ class ToolRegistry:
|
||||
args: dict[str, object],
|
||||
*,
|
||||
tags: ToolTag,
|
||||
decision: BatchConfirmDecision | None = None,
|
||||
) -> str | None:
|
||||
"""Soft-confirm gate driven by danger reason + tags + grants.
|
||||
|
||||
Pipeline (soft gate only; hard denylists stay in tool handlers)::
|
||||
|
||||
no soft reason → run
|
||||
batch decision for this call → apply it
|
||||
(tags & TRUSTABLE) and grant exists → allow
|
||||
else → on_confirm (decorator YOLO tag does not override
|
||||
dangerous invocations — internal danger classification
|
||||
@@ -491,22 +632,31 @@ class ToolRegistry:
|
||||
return None
|
||||
|
||||
async with self._confirm_lock:
|
||||
if decision is not None:
|
||||
return await self._apply_batch_decision(name, reason, decision, tags=tags)
|
||||
if self._grant_allows(name, tags=tags):
|
||||
return None
|
||||
return await self._prompt_soft_confirm(name, args, reason, tags=tags)
|
||||
|
||||
async def execute(self, name: str, arguments_json: str) -> str:
|
||||
"""Run a tool by name; returns a string result (errors become error text)."""
|
||||
async def execute(
|
||||
self,
|
||||
name: str,
|
||||
arguments_json: str,
|
||||
*,
|
||||
decision: BatchConfirmDecision | None = None,
|
||||
) -> str:
|
||||
"""Run a tool by name; returns a string result (errors become error text).
|
||||
|
||||
*decision* is the host's answer for this call from
|
||||
:meth:`prepare_batch_confirm`; ``None`` means the call was not part of an
|
||||
aggregated prompt and confirms on its own.
|
||||
"""
|
||||
definition = self._tools.get(name)
|
||||
if definition is None:
|
||||
return f"error: unknown tool {name!r}"
|
||||
try:
|
||||
raw_args: object = msgspec.json.decode(arguments_json.encode())
|
||||
except (msgspec.DecodeError, UnicodeEncodeError) as exc:
|
||||
return f"error: invalid tool arguments JSON: {exc}"
|
||||
if not isinstance(raw_args, dict):
|
||||
return "error: tool arguments must be a JSON object"
|
||||
args = {str(key): value for key, value in cast("dict[object, object]", raw_args).items()}
|
||||
args = _decode_tool_args(arguments_json)
|
||||
if isinstance(args, str):
|
||||
return args
|
||||
# Argument shape/coverage problems are answered before any confirm prompt:
|
||||
# the human should never be asked about a call that cannot run.
|
||||
missing_error = _missing_required_error(definition, args)
|
||||
@@ -522,7 +672,7 @@ 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)
|
||||
denied = await self._maybe_confirm(name, args, tags=definition.tags, decision=decision)
|
||||
if denied is not None:
|
||||
return denied
|
||||
return await self._invoke(definition, args)
|
||||
|
||||
@@ -341,7 +341,7 @@ async def _run_chat( # noqa: C901, PLR0915 — chat orchestration
|
||||
state.instance_state.workspace.path_denylist = tuple(store.agent_config.path_denylist or ())
|
||||
from plyngent.tools.net import set_ssrf_assume_public_cidrs
|
||||
|
||||
# Clash Fake-IP etc.: treat configured CIDRs as public for fetch SSRF.
|
||||
# Clash Fake-IP etc.: treat configured CIDRs as public for fetch_local SSRF.
|
||||
_ = set_ssrf_assume_public_cidrs(store.networking_config.ssrf_assume_public_cidrs)
|
||||
if interactive:
|
||||
from plyngent.cli.limits import prompt_policy_command_confirm, prompt_policy_fetch_confirm
|
||||
|
||||
@@ -21,6 +21,7 @@ from plyngent.agent import (
|
||||
TextDeltaEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
TruncatedEvent,
|
||||
UsageEvent,
|
||||
)
|
||||
from plyngent.lmproto.openai_compatible.model import AssistantFunctionToolCall
|
||||
@@ -451,13 +452,34 @@ def _http_status_fg(status: str) -> str | None:
|
||||
|
||||
|
||||
def _fetch_prefix(args_json: str) -> str:
|
||||
"""Remote ``fetch``: the URL is the whole call."""
|
||||
url = _json_str_arg(args_json, "url") or "?"
|
||||
return _pretty_prefix(f"* Fetch {url} ")
|
||||
|
||||
|
||||
def _fetch_detail(args_json: str, result: str) -> str:
|
||||
"""Remote ``fetch`` outcome: fetched size, or an error.
|
||||
|
||||
A row written before the local tool was renamed still carries the local
|
||||
``status=...`` block; render it with the local formatter.
|
||||
"""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
fields = _status_fields(result)
|
||||
if "status" in fields:
|
||||
return _fetch_local_detail(args_json, result)
|
||||
chars = fields.get("chars", "")
|
||||
return _pretty_segments((f"({chars} chars)" if chars else "(done)", "green"))
|
||||
|
||||
|
||||
def _fetch_local_prefix(args_json: str) -> str:
|
||||
method = _json_str_arg(args_json, "method") or "GET"
|
||||
url = _json_str_arg(args_json, "url") or "?"
|
||||
return _pretty_prefix(f"* Fetch {method} {url} ")
|
||||
|
||||
|
||||
def _fetch_detail(_args_json: str, result: str) -> str:
|
||||
"""``fetch`` outcome: HTTP status, or error."""
|
||||
def _fetch_local_detail(_args_json: str, result: str) -> str:
|
||||
"""``fetch_local`` outcome: HTTP status, or error."""
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
status = _status_fields(result).get("status", "")
|
||||
@@ -887,6 +909,7 @@ _PRETTY_LINES: dict[str, PrettyLine] = {
|
||||
"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),
|
||||
"fetch_local": PrettyLine(prefix=_fetch_local_prefix, detail=_fetch_local_detail),
|
||||
"web_search": PrettyLine(prefix=_web_search_prefix, detail=_web_search_detail),
|
||||
"edit_replace": _mutator_line("edit_replace"),
|
||||
"edit_lineno": _mutator_line("edit_lineno"),
|
||||
@@ -1289,6 +1312,14 @@ async def render_events( # noqa: C901, PLR0915 — one branch per event type
|
||||
)
|
||||
else:
|
||||
click.secho(f"\n[max rounds reached: {event.rounds}]", fg="red")
|
||||
elif isinstance(event, TruncatedEvent):
|
||||
flush_assistant()
|
||||
tool_lines.close_line()
|
||||
tail = "continuing" if event.continued else "/retry continues from here"
|
||||
click.secho(
|
||||
f"\n[limit] output truncated ({event.kept_chars} chars kept); {tail}",
|
||||
fg="yellow",
|
||||
)
|
||||
elif isinstance(event, UsageEvent):
|
||||
_ = event
|
||||
else:
|
||||
|
||||
+81
-12
@@ -7,6 +7,7 @@ import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from plyngent.agent import BatchConfirmDecision, 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 (
|
||||
@@ -40,6 +41,11 @@ _CONTINUE_PROMPT = "Raise limit and continue? [Y/n/yyy] (yyy = stop asking this
|
||||
_CONTINUE_YES_TOKENS = frozenset({"", "y", "yes"})
|
||||
_CONTINUE_AUTO_TOKEN = "yyy"
|
||||
|
||||
# Batch confirm answers: ``y`` allows the round's calls, ``m`` reviews them one
|
||||
# at a time through the per-call prompt.
|
||||
_BATCH_YES_TOKENS = frozenset({"y", "yes"})
|
||||
_BATCH_MANUAL_TOKENS = frozenset({"m", "manual", "each"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AutoContinue:
|
||||
@@ -105,18 +111,13 @@ def _wrap_line(text: str, width: int) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def format_tool_confirm_box(name: str, reason: str) -> str:
|
||||
"""Multi-line boxed confirm body (header + reason lines).
|
||||
|
||||
Printed with :meth:`PromptBackend.echo` before a short ``confirm()`` prompt
|
||||
so terminals keep newlines (unlike a single jammed readline line).
|
||||
"""
|
||||
def _box(header: str, lines: Sequence[str]) -> str:
|
||||
"""Rounded box: a header row, a rule, then the wrapped body lines."""
|
||||
term = _terminal_width()
|
||||
inner = max(20, term - 4) # room for ``│ `` + `` │``
|
||||
header = f"confirm · tool {name!r}"
|
||||
body_lines: list[str] = []
|
||||
for raw in reason.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
||||
body_lines.extend(_wrap_line(raw, inner - _BOX_PAD))
|
||||
for raw in lines:
|
||||
body_lines.extend(_wrap_line(raw.replace("\r\n", "\n").replace("\r", "\n"), inner - _BOX_PAD))
|
||||
content = [header, "─" * min(inner, max(len(header), 12)), *body_lines]
|
||||
width = min(inner, max(len(line) for line in content) + _BOX_PAD)
|
||||
width = max(width, min(inner, len(header) + _BOX_PAD))
|
||||
@@ -134,6 +135,15 @@ def format_tool_confirm_box(name: str, reason: str) -> str:
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def format_tool_confirm_box(name: str, reason: str) -> str:
|
||||
"""Multi-line boxed confirm body for one tool call.
|
||||
|
||||
Printed with :meth:`PromptBackend.echo` before a short ``confirm()`` prompt
|
||||
so terminals keep newlines (unlike a single jammed readline line).
|
||||
"""
|
||||
return _box(f"confirm · tool {name!r}", reason.split("\n"))
|
||||
|
||||
|
||||
def _echo_tool_confirm(name: str, reason: str) -> None:
|
||||
backend = get_prompt_backend()
|
||||
backend.echo()
|
||||
@@ -311,14 +321,14 @@ def prompt_policy_fetch_confirm(
|
||||
url: str,
|
||||
timeout_seconds: float,
|
||||
) -> bool:
|
||||
"""Ask whether to allow fetch to a private/loopback host (timed).
|
||||
"""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).
|
||||
"""
|
||||
reason = (
|
||||
f"fetch targets private/loopback host {host!r} port {port}\n"
|
||||
f"fetch_local targets private/loopback host {host!r} port {port}\n"
|
||||
f"url: {url}\n"
|
||||
f"Allow this host:port for the rest of this process?\n"
|
||||
f"(timeout {timeout_seconds:g}s defaults to DENY; not skipped by YOLO)"
|
||||
@@ -332,7 +342,7 @@ def prompt_policy_fetch_confirm(
|
||||
backend.secho(format_tool_confirm_box("policy", reason), fg="yellow")
|
||||
backend.echo()
|
||||
grant = f"{host}:{port}"
|
||||
prompt = f"[policy] allow fetch {grant!r}? [y/N] (timeout {timeout_seconds:g}s): "
|
||||
prompt = f"[policy] allow fetch_local {grant!r}? [y/N] (timeout {timeout_seconds:g}s): "
|
||||
with contextlib.suppress(OSError):
|
||||
_ = sys.stderr.write(prompt)
|
||||
_ = sys.stderr.flush()
|
||||
@@ -482,6 +492,65 @@ async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reaso
|
||||
return comment or False
|
||||
|
||||
|
||||
async def prompt_confirm_batch_async(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
|
||||
"""One confirm for one round's soft-confirm requests (``y`` / ``N`` / ``m``).
|
||||
|
||||
The answer covers exactly those calls — the next round asks again; ``m``
|
||||
asks them one at a time instead, and a deny carries one comment shared by
|
||||
every one of them.
|
||||
"""
|
||||
try:
|
||||
from plyngent.prompting import run_prompt_async
|
||||
|
||||
return await run_prompt_async(lambda: _prompt_confirm_batch_sync(requests))
|
||||
except NonInteractiveError:
|
||||
return BatchConfirmDecision(allowed=False)
|
||||
|
||||
|
||||
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)
|
||||
backend.echo()
|
||||
backend.secho(format_batch_confirm_box(requests), fg="yellow")
|
||||
backend.secho("m = review them one call at a time", fg="bright_black")
|
||||
backend.echo()
|
||||
try:
|
||||
token = (
|
||||
backend.read_line(
|
||||
f"Allow these {len(requests)} tool calls? [y/N/m]:",
|
||||
default="n",
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
except NonInteractiveError:
|
||||
return BatchConfirmDecision(allowed=False)
|
||||
if token in _BATCH_MANUAL_TOKENS:
|
||||
return BatchConfirmDecision(allowed=False, each=True)
|
||||
if token in _BATCH_YES_TOKENS:
|
||||
return BatchConfirmDecision(allowed=True)
|
||||
try:
|
||||
comment = ask(
|
||||
"Optional comment for the agent (why denied; empty to skip):",
|
||||
default="",
|
||||
).strip()
|
||||
except NonInteractiveError:
|
||||
comment = ""
|
||||
return BatchConfirmDecision(allowed=False, comment=comment or None)
|
||||
|
||||
|
||||
def format_batch_confirm_box(requests: Sequence[ToolConfirmRequest]) -> str:
|
||||
"""Boxed body listing every call a round wants to make, one numbered block each."""
|
||||
lines: list[str] = []
|
||||
for index, request in enumerate(requests, start=1):
|
||||
reason_lines = request.reason.split("\n")
|
||||
lines.append(f"{index}. [{request.name}] {reason_lines[0]}")
|
||||
lines.extend(f" {line}" for line in reason_lines[1:])
|
||||
return _box(f"confirm · {len(requests)} tool calls", lines)
|
||||
|
||||
|
||||
def _prompt_workspace_mismatch_sync(
|
||||
session_id: int,
|
||||
session_workspace: str,
|
||||
|
||||
+38
-20
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Literal
|
||||
from plyngent.agent import ChatAgent, ToolRegistry
|
||||
from plyngent.agent.loop import DEFAULT_MAX_ROUNDS
|
||||
from plyngent.agent.todo_stack import TodoStack
|
||||
from plyngent.lmproto.deepseek.webtools import DeepseekWebToolsClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
@@ -25,7 +26,7 @@ from plyngent.cli.models_source import (
|
||||
)
|
||||
from plyngent.config import EffectiveProvider, ReasoningConfig, resolve_effective_provider, resolve_reasoning
|
||||
from plyngent.memory.database.store import normalize_workspace
|
||||
from plyngent.runtime import create_client
|
||||
from plyngent.runtime import create_client, normalize_http_timeout
|
||||
from plyngent.tools import InstanceState, SessionState
|
||||
from plyngent.tools.view import MemoryViewStore, session_data_view
|
||||
|
||||
@@ -346,7 +347,7 @@ class ReplState:
|
||||
def _tool_registry(self) -> ToolRegistry | None:
|
||||
if not self.tools_enabled:
|
||||
return None
|
||||
from plyngent.cli.limits import prompt_confirm_tool_async
|
||||
from plyngent.cli.limits import prompt_confirm_batch_async, prompt_confirm_tool_async
|
||||
from plyngent.tools.catalog import register_builtin_tools
|
||||
from plyngent.tools.danger import classify_danger
|
||||
from plyngent.tools.plugins import load_plugin_tools
|
||||
@@ -374,6 +375,7 @@ class ReplState:
|
||||
tools,
|
||||
danger=classify_danger,
|
||||
on_confirm=prompt_confirm_tool_async,
|
||||
on_confirm_batch=prompt_confirm_batch_async,
|
||||
yolo=yolo,
|
||||
auto_bind_state=True,
|
||||
instance_state=self.instance_state,
|
||||
@@ -382,20 +384,43 @@ class ReplState:
|
||||
search_tool = self._web_search_tool()
|
||||
if search_tool is not None:
|
||||
registry.register(search_tool)
|
||||
fetch_tool = self._remote_fetch_tool()
|
||||
if fetch_tool is not None:
|
||||
registry.register(fetch_tool)
|
||||
return registry
|
||||
|
||||
def _deepseek_web_client(self) -> DeepseekWebToolsClient | None:
|
||||
"""The Search/Fetch API client for the active provider (None otherwise)."""
|
||||
from plyngent.config.routing import deepseek_api_base_url
|
||||
from plyngent.lmproto.openai_compatible.config import OpenAIConfig
|
||||
|
||||
provider = self.effective_provider
|
||||
if provider.preset != "deepseek":
|
||||
return None
|
||||
return DeepseekWebToolsClient(
|
||||
OpenAIConfig(
|
||||
access_key_or_token=provider.access_key_or_token,
|
||||
base_url=deepseek_api_base_url(provider),
|
||||
timeout=normalize_http_timeout(provider.timeout),
|
||||
)
|
||||
)
|
||||
|
||||
def _remote_fetch_tool(self) -> ToolDefinition | None:
|
||||
"""``fetch`` through DeepSeek's Fetch API (DeepSeek providers only)."""
|
||||
from plyngent.tools.net.fetch_remote import build_fetch_tool
|
||||
|
||||
client = self._deepseek_web_client()
|
||||
if client is None:
|
||||
return None
|
||||
return build_fetch_tool(client)
|
||||
|
||||
def _web_search_tool(self) -> ToolDefinition | None:
|
||||
"""Local ``web_search`` for a provider whose surface cannot host one.
|
||||
|
||||
The sources come from ``provider.search_sources``, in fallback order
|
||||
(see :func:`~plyngent.config.routing.local_web_search`).
|
||||
"""
|
||||
from plyngent.config.routing import (
|
||||
deepseek_search_base_url,
|
||||
local_web_search,
|
||||
)
|
||||
from plyngent.lmproto.anthropic.config import AnthropicConfig
|
||||
from plyngent.lmproto.deepseek.anthropic.search import DeepseekSearchClient
|
||||
from plyngent.config.routing import local_web_search
|
||||
from plyngent.tools.net.bing import BingSearchBackend
|
||||
from plyngent.tools.net.search import (
|
||||
DeepseekSearchBackend,
|
||||
@@ -403,22 +428,15 @@ class ReplState:
|
||||
)
|
||||
|
||||
provider = self.effective_provider
|
||||
if not local_web_search(provider) or not self.model:
|
||||
if not local_web_search(provider):
|
||||
return None
|
||||
backends: list[SearchBackend] = []
|
||||
for source in provider.search_sources:
|
||||
if source == "deepseek":
|
||||
backends.append(
|
||||
DeepseekSearchBackend(
|
||||
DeepseekSearchClient(
|
||||
AnthropicConfig(
|
||||
api_key=provider.access_key_or_token,
|
||||
base_url=deepseek_search_base_url(provider),
|
||||
)
|
||||
),
|
||||
model=self.model,
|
||||
)
|
||||
)
|
||||
client = self._deepseek_web_client()
|
||||
if client is None:
|
||||
continue
|
||||
backends.append(DeepseekSearchBackend(client))
|
||||
elif source == "bing":
|
||||
backends.append(BingSearchBackend())
|
||||
if not backends:
|
||||
|
||||
@@ -9,7 +9,8 @@ from msgspec import Struct, field
|
||||
# - "anthropic" → Anthropic Messages API (``POST /messages`` on
|
||||
# ``https://api.deepseek.com/anthropic``)
|
||||
type DeepSeekConvention = Literal["", "openai", "anthropic", "responses"]
|
||||
# Sources the local ``web_search`` tool can use (see ``SearchBackend``).
|
||||
# Sources the local ``web_search`` tool can use (see ``SearchBackend``):
|
||||
# ``deepseek`` = the platform Search API, ``bing`` = Bing's RSS backstop.
|
||||
type SearchSource = Literal["deepseek", "bing"]
|
||||
|
||||
# Static directory pre-allow mode for ``[agent].allow_paths`` (path → mode).
|
||||
@@ -70,18 +71,24 @@ with it to keep reading (chains through truncations).
|
||||
a `-c` one-liner, a script (`python x.py`), or a bare interactive shell alike — and each \
|
||||
confirm blocks the turn, so it costs time. Invoke the target command directly (argv) instead \
|
||||
of wrapping it in `bash -c` / `python -c` / `node`, and reach for a dedicated tool \
|
||||
(`read_file` / `edit_*` / `vcs_*` / `fetch` / …) when one already does the job.
|
||||
(`read_file` / `edit_*` / `vcs_*` / `fetch` / `fetch_local` / …) when one already does the job.
|
||||
- Several `run_argv` calls in one step may run in parallel; use `run_argv_batch` \
|
||||
for ordered pipelines (`pipe_out` / `mix_stderr` as needed).
|
||||
- Prefer `vcs_*` for status/diff/log/branch when enough.
|
||||
|
||||
### Network
|
||||
- Prefer `fetch` (GET/POST/PUT/DELETE) for HTTP(S) docs/APIs over curl/wget via shell; \
|
||||
`offset` (0-based) skips leading body characters to page through a large body.
|
||||
- Set `user_agent` (or a User-Agent header) 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).
|
||||
- Prefer provider hosted search (e.g. web_search) for open-ended research; use `fetch` for known URLs.
|
||||
- `fetch` reads a public page as clean Markdown through the provider's Fetch API \
|
||||
(when available, e.g. DeepSeek); `fetch_local` is the raw HTTP client \
|
||||
(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.
|
||||
- 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); \
|
||||
this applies to `fetch_local`, since `fetch` leaves from the provider's network.
|
||||
- Prefer provider hosted search (e.g. web_search) for open-ended research; use `fetch` \
|
||||
or `fetch_local` for known URLs.
|
||||
- Respect denials, size limits, and HTTPS→HTTP redirect blocks; do not re-fetch the same URL repeatedly.
|
||||
|
||||
### Humans & safety
|
||||
@@ -231,7 +238,7 @@ class McpConfig(Struct, omit_defaults=True):
|
||||
|
||||
|
||||
class NetworkingConfig(Struct, omit_defaults=True):
|
||||
"""Host-side network policy for tools such as ``fetch``.
|
||||
"""Host-side network policy for tools such as ``fetch_local``.
|
||||
|
||||
``ssrf_assume_public_cidrs``: CIDR strings whose resolved addresses are treated
|
||||
as **public for SSRF policy** (skip private-host grants). Use for Clash/meta
|
||||
@@ -385,10 +392,10 @@ def _default_deepseek_models() -> dict[str, ModelConfig]:
|
||||
def _default_deepseek_search_sources() -> list[SearchSource]:
|
||||
"""Sources for the local ``web_search`` tool, in fallback order.
|
||||
|
||||
DeepSeek's own index first (it is the provider's own search), Bing's RSS as
|
||||
the backstop when a search fails or finds nothing.
|
||||
DeepSeek's own index (the platform Search API) is the default. Bing's RSS is
|
||||
a brittle, SEO-heavy backstop: list ``"bing"`` explicitly to opt in.
|
||||
"""
|
||||
return ["deepseek", "bing"]
|
||||
return ["deepseek"]
|
||||
|
||||
|
||||
class DeepseekProvider(ProviderConfig, tag="deepseek"):
|
||||
@@ -401,11 +408,14 @@ class DeepseekProvider(ProviderConfig, tag="deepseek"):
|
||||
``https://api.deepseek.com/anthropic``)
|
||||
|
||||
``provider_tools`` asks for a search tool like OpenAI's does (``[]``
|
||||
disables it). DeepSeek runs a hosted search only on the Anthropic surface, so
|
||||
the other conventions get a local ``web_search`` tool instead (see
|
||||
``config.routing.local_web_search``); ``search_sources`` names what that tool
|
||||
searches with, in fallback order — ``["deepseek", "bing"]`` by default,
|
||||
``[]`` to not offer it.
|
||||
disables the hosted one). DeepSeek hosts a search only on the Anthropic
|
||||
surface; every other convention gets a local ``web_search`` tool instead
|
||||
(see ``config.routing.local_web_search``). ``search_sources`` names what
|
||||
that local tool searches with, in fallback order — ``["deepseek"]`` by
|
||||
default (the platform Search API), ``[]`` to not offer it. ``"bing"`` adds
|
||||
Bing's RSS as an opt-in backstop. The two settings are independent: turning
|
||||
hosted tools off on the Anthropic convention leaves the local Search API tool
|
||||
in place.
|
||||
|
||||
``extras`` keeps arbitrary provider keys; the legacy ``extras.convention``
|
||||
key is parsed but ignored (prefer the typed ``convention`` field).
|
||||
|
||||
@@ -98,27 +98,34 @@ def wants_web_search(effective: EffectiveProvider) -> bool:
|
||||
def local_web_search(effective: EffectiveProvider) -> bool:
|
||||
"""True when this provider searches through plyngent's own ``web_search`` tool.
|
||||
|
||||
DeepSeek runs a hosted search only on its Anthropic-compatible surface
|
||||
(``convention = "anthropic"``): the Responses and chat-completions surfaces
|
||||
ignore a hosted tool — a live probe answers "I can't browse the web" with
|
||||
either ``{"type": "web_search"}`` or the server-tool shape — so a DeepSeek
|
||||
provider that talks them searches through
|
||||
:func:`~plyngent.tools.net.search.build_web_search_tool`, which asks the
|
||||
sources named by ``search_sources`` behind the scenes.
|
||||
``search_sources`` owns the local tool; ``provider_tools`` owns the hosted
|
||||
one. DeepSeek's Anthropic-compatible surface can run the search inside a
|
||||
response, so that combination keeps the hosted tool and the local one stays
|
||||
out of the way; turning hosted tools off (``provider_tools = []``) brings
|
||||
the local tool back there too. Every other surface ignores a hosted search,
|
||||
so the local tool is the only one it can use.
|
||||
"""
|
||||
if effective.preset != "deepseek" or effective.convention == "anthropic":
|
||||
if effective.preset != "deepseek" or not effective.search_sources:
|
||||
return False
|
||||
if not effective.search_sources:
|
||||
return False
|
||||
return wants_web_search(effective)
|
||||
return not (effective.convention == "anthropic" and wants_web_search(effective))
|
||||
|
||||
|
||||
def deepseek_search_base_url(effective: EffectiveProvider) -> str:
|
||||
"""The endpoint a DeepSeek search turn runs on (its Anthropic-compatible base)."""
|
||||
url = effective.url.rstrip("/")
|
||||
if url.endswith("/anthropic"):
|
||||
return url
|
||||
return DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
|
||||
def deepseek_api_base_url(effective: EffectiveProvider) -> str:
|
||||
"""The platform base URL shared by model and tool APIs.
|
||||
|
||||
The Anthropic convention lives under ``/anthropic`` and OpenAI-compatible
|
||||
configs often carry a ``/v1`` suffix; neither belongs in the base that the
|
||||
Search API client appends ``/v1/tools/search`` to.
|
||||
"""
|
||||
url = (effective.url or DEFAULT_DEEPSEEK_BASE_URL).strip().rstrip("/")
|
||||
stripped = True
|
||||
while stripped:
|
||||
stripped = False
|
||||
for suffix in ("/anthropic", "/v1"):
|
||||
if url.endswith(suffix):
|
||||
url = url[: -len(suffix)].rstrip("/")
|
||||
stripped = True
|
||||
return url or DEFAULT_DEEPSEEK_BASE_URL
|
||||
|
||||
|
||||
def resolve_effective_provider(provider: Provider, *, model: str | None = None) -> EffectiveProvider:
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
"""Server-side web search on DeepSeek's Anthropic-compatible endpoint.
|
||||
|
||||
DeepSeek runs ``web_search`` *inside* a turn: the request carries the server
|
||||
tool, and the response comes back with a ``server_tool_use`` block per query
|
||||
plus the ``web_search_tool_result`` that query produced — nothing is executed
|
||||
here. Results only carry a title, a URL and an ``encrypted_content`` the index
|
||||
keeps the page body in, which is why the hit list is all a caller can pass on.
|
||||
|
||||
Only this surface searches: the Responses and chat-completions surfaces ignore a
|
||||
hosted search tool (a live probe answers "I can't browse the web" whether the
|
||||
tool arrives as ``{"type": "web_search"}`` or in the server-tool shape), so a
|
||||
DeepSeek provider that talks them searches through
|
||||
:func:`~plyngent.tools.net.search.build_web_search_tool` instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import msgspec
|
||||
import niquests
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from plyngent.lmproto.anthropic.config import AnthropicConfig
|
||||
|
||||
# A search turn stops as soon as the search came back: the answer that follows is
|
||||
# thrown away, so the budget only has to cover the thinking that picks the query
|
||||
# and the results. A live probe with 64 tokens spent all of it before searching
|
||||
# and returned no results at all.
|
||||
DEFAULT_SEARCH_MAX_TOKENS = 512
|
||||
DEFAULT_SEARCH_MAX_USES = 3
|
||||
_HTTP_ERROR = 400
|
||||
_SEARCH_TOOL: dict[str, Any] = {"type": "web_search_20250305", "name": "web_search"}
|
||||
|
||||
|
||||
class SearchHit(msgspec.Struct, frozen=True, omit_defaults=True):
|
||||
"""One page a search source matched.
|
||||
|
||||
DeepSeek's index keeps the page bodies server-side, so its hits carry a
|
||||
title and a URL only; a source that ships snippets (Bing's RSS) fills
|
||||
``snippet`` too.
|
||||
"""
|
||||
|
||||
title: str = ""
|
||||
url: str = ""
|
||||
snippet: str = ""
|
||||
|
||||
|
||||
def hits_from_content(content: object) -> list[SearchHit]:
|
||||
"""Collect the search hits from a response's content blocks (deduplicated)."""
|
||||
if not isinstance(content, list):
|
||||
return []
|
||||
hits: list[SearchHit] = []
|
||||
seen: set[str] = set()
|
||||
for block_obj in cast("list[object]", content):
|
||||
if not isinstance(block_obj, dict):
|
||||
continue
|
||||
block = cast("dict[str, object]", block_obj)
|
||||
if block.get("type") != "web_search_tool_result":
|
||||
continue
|
||||
entries = block.get("content")
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for entry_obj in cast("list[object]", entries):
|
||||
if not isinstance(entry_obj, dict):
|
||||
continue
|
||||
entry = cast("dict[str, object]", entry_obj)
|
||||
if entry.get("type") != "web_search_result":
|
||||
continue
|
||||
url = entry.get("url")
|
||||
if not isinstance(url, str) or not url or url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
title = entry.get("title")
|
||||
hits.append(SearchHit(title=title if isinstance(title, str) else "", url=url))
|
||||
return hits
|
||||
|
||||
|
||||
class DeepseekSearchClient:
|
||||
"""``POST /messages`` with a hosted ``web_search`` tool, read for its hits."""
|
||||
|
||||
session: niquests.AsyncSession
|
||||
encoder: msgspec.json.Encoder
|
||||
_api_key: str
|
||||
_api_version: str
|
||||
|
||||
def __init__(self, config: AnthropicConfig) -> None:
|
||||
self.session = niquests.AsyncSession(base_url=config.base_url, timeout=config.timeout)
|
||||
self.encoder = msgspec.json.Encoder()
|
||||
self._api_key = config.api_key
|
||||
self._api_version = config.anthropic_version
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": self._api_key,
|
||||
"anthropic-version": self._api_version,
|
||||
}
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
model: str,
|
||||
max_uses: int = DEFAULT_SEARCH_MAX_USES,
|
||||
max_tokens: int = DEFAULT_SEARCH_MAX_TOKENS,
|
||||
) -> list[SearchHit]:
|
||||
"""Run *query* through the server's search; return the pages it matched."""
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": [{"role": "user", "content": query}],
|
||||
"tools": [{**_SEARCH_TOOL, "max_uses": max_uses}],
|
||||
}
|
||||
resp = await self.session.post(
|
||||
"/messages",
|
||||
data=self.encoder.encode(payload),
|
||||
headers=self._headers(),
|
||||
stream=False,
|
||||
)
|
||||
body = await self._read_body(resp)
|
||||
parsed = msgspec.json.decode(body, type=dict[str, object])
|
||||
return hits_from_content(parsed.get("content"))
|
||||
|
||||
async def _read_body(self, resp: object) -> bytes:
|
||||
from plyngent.lmproto.openai_compatible.client import (
|
||||
http_error_message,
|
||||
read_response_body,
|
||||
)
|
||||
|
||||
body = await read_response_body(resp)
|
||||
status = int(getattr(resp, "status_code", 0) or 0)
|
||||
if status >= _HTTP_ERROR:
|
||||
detail = http_error_message(status, body, what="search") or f"search HTTP {status}"
|
||||
raise RuntimeError(detail)
|
||||
if body is None:
|
||||
msg = "search response body is empty"
|
||||
raise RuntimeError(msg)
|
||||
return body.encode() if isinstance(body, str) else bytes(body)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_SEARCH_MAX_TOKENS",
|
||||
"DEFAULT_SEARCH_MAX_USES",
|
||||
"DeepseekSearchClient",
|
||||
"SearchHit",
|
||||
"hits_from_content",
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
from .client import DeepseekWebToolsClient as DeepseekWebToolsClient
|
||||
from .model import DEFAULT_SEARCH_COUNT as DEFAULT_SEARCH_COUNT
|
||||
from .model import FETCH_PATH as FETCH_PATH
|
||||
from .model import MAX_FETCH_URL_CHARS as MAX_FETCH_URL_CHARS
|
||||
from .model import MAX_QUERY_CHARS as MAX_QUERY_CHARS
|
||||
from .model import MAX_SEARCH_COUNT as MAX_SEARCH_COUNT
|
||||
from .model import SEARCH_PATH as SEARCH_PATH
|
||||
from .model import FetchRequest as FetchRequest
|
||||
from .model import FetchResponse as FetchResponse
|
||||
from .model import SearchRequest as SearchRequest
|
||||
from .model import SearchResponse as SearchResponse
|
||||
from .model import SearchResult as SearchResult
|
||||
@@ -0,0 +1,194 @@
|
||||
"""DeepSeek's Search and Fetch APIs: plain JSON, one bearer key.
|
||||
|
||||
``POST /v1/tools/search`` answers a query in one request, without spending a
|
||||
model round — the endpoint replaces the older server-side search turn, which
|
||||
cost a whole completion round (and its input tokens) just to pick the query and
|
||||
could only return titles and URLs. A Search response already carries a snippet
|
||||
and a model-facing summary, so the local ``web_search`` tool can hand the model
|
||||
something readable on the first call.
|
||||
|
||||
``POST /v1/tools/fetch`` reads one public page and returns its title and content
|
||||
as Markdown, for the remote ``fetch`` tool. Both endpoints share the session,
|
||||
authentication, error shape, retry policy and rate limits.
|
||||
|
||||
``count`` does not affect billing (one successful request costs the same for 1
|
||||
or 50 results); only successful requests are billable, so the retries below are
|
||||
safe. The account-level limit is 5 QPS and is shared across the Search and Fetch
|
||||
APIs independently of model concurrency; Fetch also has a per-domain limit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import msgspec
|
||||
import niquests
|
||||
from niquests.auth import BearerTokenAuth
|
||||
|
||||
from plyngent.lmproto.openai_compatible.client import (
|
||||
http_error_message,
|
||||
read_response_body,
|
||||
)
|
||||
from plyngent.lmproto.search import SearchHit
|
||||
|
||||
from .model import (
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
FETCH_PATH,
|
||||
MAX_FETCH_URL_CHARS,
|
||||
MAX_QUERY_CHARS,
|
||||
MAX_SEARCH_COUNT,
|
||||
SEARCH_PATH,
|
||||
FetchRequest,
|
||||
FetchResponse,
|
||||
SearchRequest,
|
||||
SearchResponse,
|
||||
SearchResult,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from plyngent.lmproto.openai_compatible.config import OpenAIConfig
|
||||
|
||||
_HTTP_ERROR = 400
|
||||
_SERVER_ERROR = 500
|
||||
# A response is retried when the server (or the account's QPS window) is the
|
||||
# problem; a request-shaped error is never retried.
|
||||
_RETRYABLE_STATUS = frozenset({408, 429})
|
||||
RETRY_DELAYS: tuple[float, ...] = (0.5, 1.0)
|
||||
_SNIPPET_FALLBACK_CHARS = 200
|
||||
|
||||
|
||||
class DeepseekWebToolsClient:
|
||||
"""``POST /v1/tools/search`` and ``/v1/tools/fetch`` on one session."""
|
||||
|
||||
session: niquests.AsyncSession
|
||||
encoder: msgspec.json.Encoder
|
||||
decoder: msgspec.json.Decoder[SearchResponse]
|
||||
fetch_decoder: msgspec.json.Decoder[FetchResponse]
|
||||
|
||||
def __init__(self, config: OpenAIConfig) -> None:
|
||||
self.session = niquests.AsyncSession(
|
||||
base_url=config.base_url,
|
||||
auth=BearerTokenAuth(config.access_key_or_token),
|
||||
timeout=config.timeout,
|
||||
)
|
||||
self.encoder = msgspec.json.Encoder()
|
||||
self.decoder = msgspec.json.Decoder(SearchResponse)
|
||||
self.fetch_decoder = msgspec.json.Decoder(FetchResponse)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
count: int = DEFAULT_SEARCH_COUNT,
|
||||
need_content: bool = False,
|
||||
) -> list[SearchHit]:
|
||||
"""Run *query* and return the pages the index ranked.
|
||||
|
||||
The query is forwarded with its search syntax intact (``site:``,
|
||||
``lang:``, ``after:``, quoted phrases, …), so the caller decides how
|
||||
specific to be. ``need_content`` asks the index for page bodies; the
|
||||
local tool leaves it off and reads a page with ``fetch`` instead.
|
||||
"""
|
||||
stripped = query.strip()
|
||||
if not stripped:
|
||||
msg = "search query must not be empty"
|
||||
raise ValueError(msg)
|
||||
if len(query) > MAX_QUERY_CHARS:
|
||||
msg = f"search query must be at most {MAX_QUERY_CHARS} characters ({len(query)} given)"
|
||||
raise ValueError(msg)
|
||||
payload = SearchRequest(
|
||||
query=query,
|
||||
count=max(1, min(count, MAX_SEARCH_COUNT)),
|
||||
need_content=need_content,
|
||||
)
|
||||
body = await self._post(SEARCH_PATH, self.encoder.encode(payload))
|
||||
parsed = self.decoder.decode(body)
|
||||
return [hit for hit in map(_to_hit, parsed.results) if hit.url]
|
||||
|
||||
async def fetch(self, url: str) -> FetchResponse:
|
||||
"""Read *url* and return its title and Markdown content.
|
||||
|
||||
The page is fetched from DeepSeek's network, not from this host: local
|
||||
SSRF checks do not apply, and neither do private-host grants — a LAN or
|
||||
loopback target is simply unreachable there (the endpoint answers 403 or
|
||||
404). The URL is still validated here so a malformed request fails
|
||||
before it costs a round trip.
|
||||
"""
|
||||
_validate_fetch_url(url)
|
||||
body = await self._post(FETCH_PATH, self.encoder.encode(FetchRequest(url=url)))
|
||||
return self.fetch_decoder.decode(body)
|
||||
|
||||
async def _post(self, path: str, data: bytes) -> bytes:
|
||||
"""POST *data*, retrying the statuses that mean "wait and try again"."""
|
||||
for delay in RETRY_DELAYS:
|
||||
status, body = await self._send(path, data)
|
||||
if not _retryable(status):
|
||||
return _require_body(status, body)
|
||||
await asyncio.sleep(delay)
|
||||
status, body = await self._send(path, data)
|
||||
return _require_body(status, body)
|
||||
|
||||
async def _send(self, path: str, data: bytes) -> tuple[int, bytes | str | None]:
|
||||
resp = await self.session.post(
|
||||
path,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
stream=False,
|
||||
)
|
||||
body = await read_response_body(resp)
|
||||
return int(getattr(resp, "status_code", 0) or 0), body
|
||||
|
||||
|
||||
def _retryable(status: int) -> bool:
|
||||
return status in _RETRYABLE_STATUS or status >= _SERVER_ERROR
|
||||
|
||||
|
||||
def _require_body(status: int, body: bytes | str | None) -> bytes:
|
||||
"""Return a successful body; raise the endpoint's error otherwise."""
|
||||
if status >= _HTTP_ERROR:
|
||||
detail = http_error_message(status, body, what="search") or f"search HTTP {status}"
|
||||
raise RuntimeError(detail)
|
||||
if body is None:
|
||||
msg = "search response body is empty"
|
||||
raise RuntimeError(msg)
|
||||
return body.encode() if isinstance(body, str) else bytes(body)
|
||||
|
||||
|
||||
def _to_hit(result: SearchResult) -> SearchHit:
|
||||
snippet = _collapse(result.snippet)
|
||||
if not snippet:
|
||||
snippet = _shorten(_collapse(result.summary))
|
||||
return SearchHit(title=result.title.strip(), url=result.url.strip(), snippet=snippet)
|
||||
|
||||
|
||||
def _collapse(text: str) -> str:
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def _shorten(text: str) -> str:
|
||||
if len(text) <= _SNIPPET_FALLBACK_CHARS:
|
||||
return text
|
||||
return text[: _SNIPPET_FALLBACK_CHARS - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _validate_fetch_url(url: str) -> None:
|
||||
"""Reject a URL the endpoint would refuse, before paying for the request."""
|
||||
if not url or len(url) > MAX_FETCH_URL_CHARS:
|
||||
msg = f"fetch url must be 1-{MAX_FETCH_URL_CHARS} characters"
|
||||
raise ValueError(msg)
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
except ValueError:
|
||||
msg = "fetch url is malformed"
|
||||
raise ValueError(msg) from None
|
||||
if parts.scheme not in {"http", "https"} or not parts.hostname:
|
||||
msg = "fetch url must be an absolute http(s) URL with a host"
|
||||
raise ValueError(msg)
|
||||
if parts.username or parts.password:
|
||||
msg = "fetch url must not carry a username or password"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
__all__ = ["RETRY_DELAYS", "DeepseekWebToolsClient"]
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Wire models for DeepSeek's Search and Fetch APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import msgspec
|
||||
|
||||
SEARCH_PATH = "/v1/tools/search"
|
||||
FETCH_PATH = "/v1/tools/fetch"
|
||||
DEFAULT_SEARCH_COUNT = 10
|
||||
MAX_SEARCH_COUNT = 50
|
||||
MAX_QUERY_CHARS = 128
|
||||
MAX_FETCH_URL_CHARS = 8192
|
||||
|
||||
|
||||
class SearchRequest(msgspec.Struct, omit_defaults=True):
|
||||
"""Body of one search request (unknown optional fields are left out)."""
|
||||
|
||||
query: str
|
||||
count: int = DEFAULT_SEARCH_COUNT
|
||||
ori_question: str | None = None
|
||||
need_content: bool = False
|
||||
|
||||
|
||||
class SearchResult(msgspec.Struct, omit_defaults=True):
|
||||
"""One ranked result; unknown fields are ignored on decode."""
|
||||
|
||||
title: str = ""
|
||||
url: str = ""
|
||||
snippet: str = ""
|
||||
# A 600-800 character summary "intended to be read by a model"; used as the
|
||||
# snippet when the index has no short excerpt.
|
||||
summary: str = ""
|
||||
rerank_score: float = 0.0
|
||||
authority: int = -1
|
||||
|
||||
|
||||
class SearchResponse(msgspec.Struct):
|
||||
"""Search results; empty when the query matched nothing (still HTTP 200)."""
|
||||
|
||||
results: list[SearchResult]
|
||||
|
||||
|
||||
class FetchRequest(msgspec.Struct):
|
||||
"""Body of one fetch request: a single absolute http(s) URL."""
|
||||
|
||||
url: str
|
||||
|
||||
|
||||
class FetchResponse(msgspec.Struct):
|
||||
"""A fetched page: its title and its content rendered as Markdown."""
|
||||
|
||||
title: str = ""
|
||||
content: str = ""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_SEARCH_COUNT",
|
||||
"FETCH_PATH",
|
||||
"MAX_FETCH_URL_CHARS",
|
||||
"MAX_QUERY_CHARS",
|
||||
"MAX_SEARCH_COUNT",
|
||||
"SEARCH_PATH",
|
||||
"FetchRequest",
|
||||
"FetchResponse",
|
||||
"SearchRequest",
|
||||
"SearchResponse",
|
||||
"SearchResult",
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Provider-neutral models shared by the local ``web_search`` sources.
|
||||
|
||||
A search source answers a query with a list of pages; the local tool in
|
||||
``plyngent.tools.net.search`` formats them and the CLI wires whichever sources
|
||||
``provider.search_sources`` names. Keeping the result type here (rather than in
|
||||
one provider's package) lets the generic sources — Bing's RSS, DeepSeek's Search
|
||||
API — speak the same shape without importing each other.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import msgspec
|
||||
|
||||
|
||||
class SearchHit(msgspec.Struct, frozen=True, omit_defaults=True):
|
||||
"""One page a search source matched.
|
||||
|
||||
``snippet`` is whatever short excerpt the source ships: a real snippet from
|
||||
Bing, or DeepSeek's ``summary`` when its index has no snippet for the page.
|
||||
"""
|
||||
|
||||
title: str = ""
|
||||
url: str = ""
|
||||
snippet: str = ""
|
||||
|
||||
|
||||
__all__ = ["SearchHit"]
|
||||
@@ -46,7 +46,7 @@ from .file import write_file as write_file
|
||||
from .net import NET_TOOLS as NET_TOOLS
|
||||
from .net import clear_private_grants as clear_private_grants
|
||||
from .net import clear_ssrf_assume_public_cidrs as clear_ssrf_assume_public_cidrs
|
||||
from .net import fetch as fetch
|
||||
from .net import fetch_local as fetch_local
|
||||
from .net import grant_private_host as grant_private_host
|
||||
from .net import set_fetch_policy_confirm_hook as set_fetch_policy_confirm_hook
|
||||
from .net import set_ssrf_assume_public_cidrs as set_ssrf_assume_public_cidrs
|
||||
|
||||
@@ -155,7 +155,7 @@ _read_only: ContextVar[bool] = ContextVar("plyngent_read_only_context", default=
|
||||
def read_only_context() -> Generator[None]:
|
||||
"""Mark the current context as read-only for tool execution.
|
||||
|
||||
Tools that may mutate depending on arguments (e.g. ``fetch`` with a
|
||||
Tools that may mutate depending on arguments (e.g. ``fetch_local`` with a
|
||||
non-GET method) check :func:`is_read_only_context` and refuse to mutate
|
||||
while it is set.
|
||||
"""
|
||||
|
||||
@@ -192,8 +192,8 @@ def _open_pty_reason(args: Mapping[str, object]) -> str | None:
|
||||
|
||||
|
||||
def _fetch_reason(args: Mapping[str, object]) -> str | None:
|
||||
"""Soft-confirm cleartext HTTP and mutating fetch methods (not private-host policy)."""
|
||||
from plyngent.tools.net.fetch import fetch_soft_reason
|
||||
"""Soft-confirm cleartext HTTP and mutating fetch_local methods (not private-host policy)."""
|
||||
from plyngent.tools.net.fetch_local import fetch_soft_reason
|
||||
|
||||
method_obj = args.get("method", "GET")
|
||||
url_obj = args.get("url", "")
|
||||
@@ -211,7 +211,7 @@ def classify_danger(name: str, args: Mapping[str, object]) -> str | None: # noq
|
||||
soft confirms for mutating tools and risky command launches — interpreters
|
||||
(however invoked) and detached runners such as ``nohup``, resolved past
|
||||
environment wrappers and ``<launcher> run``.
|
||||
Private/loopback fetch targets use a separate policy grant (not YOLO).
|
||||
Private/loopback fetch_local targets use a separate policy grant (not YOLO).
|
||||
"""
|
||||
if name == "delete_path":
|
||||
return _delete_path_reason(args)
|
||||
@@ -227,6 +227,6 @@ def classify_danger(name: str, args: Mapping[str, object]) -> str | None: # noq
|
||||
return _run_argv_batch_reason(args)
|
||||
if name == "open_pty":
|
||||
return _open_pty_reason(args)
|
||||
if name == "fetch":
|
||||
if name == "fetch_local":
|
||||
return _fetch_reason(args)
|
||||
return None
|
||||
|
||||
@@ -60,14 +60,15 @@ async def get_truncated(token: str, *, max_chars: int = DEFAULT_TOOL_RESULT_MAX_
|
||||
"""Fetch the next chunk of a truncated result using its ``truncate_token``.
|
||||
|
||||
Pass the token shown at the end of a truncated ``read_file``, ``fetch``,
|
||||
``run_argv``, or earlier ``get_truncated`` result to keep reading without
|
||||
re-requesting the whole source or raising limits. Truncated output carries a
|
||||
fresh token, so chunks chain indefinitely. File chunks resume in the same
|
||||
view as the original read: numbered reads continue as numbered lines
|
||||
(editable via ``edit_lineno``), plain reads as raw text with a 1-based
|
||||
``L{begin}-{end}`` range. Memory chunks (generic tool output, and ``fetch``
|
||||
bodies) are served from a short-lived in-memory store that is forgotten when
|
||||
the agent exits, so resuming a fetch never repeats the request.
|
||||
``fetch_local``, ``run_argv``, or earlier ``get_truncated`` result to keep
|
||||
reading without re-requesting the whole source or raising limits. Truncated
|
||||
output carries a fresh token, so chunks chain indefinitely. File chunks
|
||||
resume in the same view as the original read: numbered reads continue as
|
||||
numbered lines (editable via ``edit_lineno``), plain reads as raw text with
|
||||
a 1-based ``L{begin}-{end}`` range. Memory chunks (generic tool output, and
|
||||
``fetch``/``fetch_local`` bodies) are served from a short-lived in-memory
|
||||
store that is forgotten when the agent exits, so resuming a fetch never
|
||||
repeats the request.
|
||||
"""
|
||||
parsed = decode_truncate_token(token)
|
||||
if parsed is None:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .fetch import fetch as fetch
|
||||
from .fetch_local import fetch_local as fetch_local
|
||||
from .grants import clear_private_grants as clear_private_grants
|
||||
from .grants import grant_private_host as grant_private_host
|
||||
from .grants import set_fetch_policy_confirm_hook as set_fetch_policy_confirm_hook
|
||||
@@ -6,5 +6,5 @@ from .policy import clear_ssrf_assume_public_cidrs as clear_ssrf_assume_public_c
|
||||
from .policy import set_ssrf_assume_public_cidrs as set_ssrf_assume_public_cidrs
|
||||
|
||||
NET_TOOLS = [
|
||||
fetch,
|
||||
fetch_local,
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Bing answers a query with a plain RSS document (``?format=rss``): no key, no
|
||||
page scraping, and each ``<item>`` already carries a title, a link and a
|
||||
description (the snippet DeepSeek's own index does not hand out). The request
|
||||
goes through the fetch client the ``fetch`` tool uses, so the SSRF and
|
||||
goes through the fetch client the ``fetch_local`` tool uses, so the SSRF and
|
||||
private-host grant policy applies to it as well.
|
||||
"""
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
import urllib.parse
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from plyngent.lmproto.deepseek.anthropic.search import SearchHit
|
||||
from plyngent.lmproto.search import SearchHit
|
||||
from plyngent.tools.net.client import http_fetch
|
||||
|
||||
SEARCH_URL = "https://www.bing.com/search"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Model-facing ``fetch`` tool (HTTP GET/POST/PUT/DELETE)."""
|
||||
"""Model-facing ``fetch_local`` tool (HTTP GET/POST/PUT/DELETE from this host)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -62,7 +62,7 @@ def format_fetch_result(
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO | ToolTag.TRUSTABLE | ToolTag.READ_ONLY)
|
||||
async def fetch(
|
||||
async def fetch_local(
|
||||
url: str,
|
||||
*,
|
||||
method: str = "GET",
|
||||
@@ -97,7 +97,7 @@ async def fetch(
|
||||
try:
|
||||
verb = normalize_method(method)
|
||||
if is_read_only_context() and verb != "GET":
|
||||
return "error: fetch method must be GET in a read-only context (POST/PUT/DELETE mutate)"
|
||||
return "error: fetch_local method must be GET in a read-only context (POST/PUT/DELETE mutate)"
|
||||
parsed = parse_fetch_url(url)
|
||||
hdrs = normalize_request_headers(headers, user_agent=user_agent)
|
||||
body_bytes: bytes | None = None
|
||||
@@ -119,7 +119,7 @@ async def fetch(
|
||||
except FetchPolicyError as exc:
|
||||
return f"error: {exc}"
|
||||
except Exception as exc: # noqa: BLE001 — tool surface returns error text
|
||||
return f"error: fetch failed: {exc}"
|
||||
return f"error: fetch_local failed: {exc}"
|
||||
|
||||
return format_fetch_result(
|
||||
status=result.status,
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Model-facing ``fetch``: read a public page through DeepSeek's Fetch API.
|
||||
|
||||
The page is rendered to Markdown server-side, so it is the friendly counterpart
|
||||
of ``fetch_local``: use it for public articles/docs, and keep ``fetch_local``
|
||||
for localhost/LAN targets, non-GET requests, custom headers, and raw status or
|
||||
redirect inspection. The request leaves from DeepSeek's network, not this host,
|
||||
so local SSRF checks and private-host grants do not apply here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
FETCH_TOOL_NAME = "fetch"
|
||||
|
||||
|
||||
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)
|
||||
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:
|
||||
"""A ``fetch`` definition backed by DeepSeek's Fetch API."""
|
||||
|
||||
async def fetch(url: str, offset: int = 0, max_chars: int = default_max_chars) -> 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
|
||||
is fetched from DeepSeek's network, so localhost/LAN targets are
|
||||
unreachable here — use ``fetch_local`` for those, for non-GET methods, or
|
||||
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).
|
||||
"""
|
||||
try:
|
||||
page = await client.fetch(url)
|
||||
except Exception as exc: # noqa: BLE001 — tool surface returns error text
|
||||
return f"error: fetch failed: {exc}"
|
||||
return format_page_result(
|
||||
title=page.title,
|
||||
url=url,
|
||||
content=page.content,
|
||||
max_chars=max_chars,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return ToolDefinition(
|
||||
name=FETCH_TOOL_NAME,
|
||||
description=inspect.getdoc(fetch) or "",
|
||||
parameters=schema_from_callable(fetch),
|
||||
handler=fetch,
|
||||
tags=ToolTag.LOCAL | ToolTag.READ_ONLY,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["FETCH_TOOL_NAME", "build_fetch_tool", "format_page_result"]
|
||||
@@ -77,7 +77,7 @@ async def ensure_host_allowed(
|
||||
try:
|
||||
inst = instance if instance is not None else require_bound_instance()
|
||||
except WorkspaceError as exc:
|
||||
msg = f"instance state is not bound for fetch policy: {exc}"
|
||||
msg = f"instance state is not bound for fetch_local policy: {exc}"
|
||||
raise FetchPolicyError(msg) from exc
|
||||
|
||||
parsed = parse_fetch_url(url)
|
||||
@@ -85,7 +85,7 @@ async def ensure_host_allowed(
|
||||
if assessment.classification is HostClass.PUBLIC:
|
||||
return
|
||||
if assessment.classification is HostClass.FORBIDDEN:
|
||||
msg = f"fetch blocked forbidden host {parsed.host!r} (addresses: {', '.join(assessment.addresses)})"
|
||||
msg = f"fetch_local blocked forbidden host {parsed.host!r} (addresses: {', '.join(assessment.addresses)})"
|
||||
raise FetchPolicyError(msg)
|
||||
|
||||
# PRIVATE
|
||||
@@ -100,18 +100,18 @@ async def ensure_host_allowed(
|
||||
hook = get_fetch_policy_confirm_hook(inst)
|
||||
if hook is None:
|
||||
msg = (
|
||||
f"fetch blocked private/loopback host {parsed.host}:{parsed.port} "
|
||||
f"fetch_local blocked private/loopback host {parsed.host}:{parsed.port} "
|
||||
f"({assessment.reason}; no policy confirm hook / non-interactive deny)"
|
||||
)
|
||||
raise FetchPolicyError(msg)
|
||||
try:
|
||||
allowed = bool(hook(parsed.host, parsed.port, parsed.url, timeout))
|
||||
except Exception as exc:
|
||||
msg = f"fetch blocked private/loopback host {parsed.host}:{parsed.port} (confirm failed: {exc})"
|
||||
msg = f"fetch_local blocked private/loopback host {parsed.host}:{parsed.port} (confirm failed: {exc})"
|
||||
raise FetchPolicyError(msg) from exc
|
||||
if not allowed:
|
||||
msg = (
|
||||
f"fetch blocked private/loopback host {parsed.host}:{parsed.port} "
|
||||
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)"
|
||||
)
|
||||
raise FetchPolicyError(msg)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""URL / host policy for the fetch tool (SSRF, private grants, cleartext)."""
|
||||
"""URL / host policy for the fetch_local tool (SSRF, private grants, cleartext)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"""``web_search``: the model's way to search when its surface cannot host one.
|
||||
|
||||
DeepSeek only searches on its Anthropic-compatible surface, so a DeepSeek
|
||||
provider that talks the Responses or chat-completions surface gets this local
|
||||
tool instead of a hosted one (see ``config.routing.local_web_search``): the
|
||||
model calls it like any other tool and the handler asks the configured search
|
||||
sources behind the scenes — ``provider.search_sources``, in fallback order, so
|
||||
the next source is tried when one fails or finds nothing. A hit is a title, a
|
||||
URL and (when the source has one) a snippet: read a page with ``fetch`` before
|
||||
relying on its content.
|
||||
DeepSeek hosts a search only on its Anthropic-compatible surface, so a DeepSeek
|
||||
provider that talks the Responses or chat-completions surface — or one that
|
||||
turns hosted tools off — gets this local tool instead (see
|
||||
``config.routing.local_web_search``): the model calls it like any other tool and
|
||||
the handler asks the configured search sources behind the scenes —
|
||||
``provider.search_sources``, in fallback order, so the next source is tried when
|
||||
one fails or finds nothing. DeepSeek's own source is the Search API
|
||||
(``POST /v1/tools/search``), which answers in one request instead of a model
|
||||
round. A hit is a title, a URL and (when the source has one) a snippet: read a
|
||||
page with ``fetch`` (or ``fetch_local`` for local/LAN targets) before relying on
|
||||
its content.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,10 +23,8 @@ from plyngent.agent.tools import ToolDefinition, ToolTag, schema_from_callable
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from plyngent.lmproto.deepseek.anthropic.search import (
|
||||
DeepseekSearchClient,
|
||||
SearchHit,
|
||||
)
|
||||
from plyngent.lmproto.deepseek.webtools import DeepseekWebToolsClient
|
||||
from plyngent.lmproto.search import SearchHit
|
||||
|
||||
WEB_SEARCH_TOOL_NAME = "web_search"
|
||||
DEFAULT_MAX_HITS = 8
|
||||
@@ -36,17 +37,15 @@ class SearchBackend(Protocol):
|
||||
|
||||
|
||||
class DeepseekSearchBackend:
|
||||
"""DeepSeek's own index: one server-side search turn per query."""
|
||||
"""DeepSeek's own index: one Search API request per query."""
|
||||
|
||||
_client: DeepseekSearchClient
|
||||
_model: str
|
||||
_client: DeepseekWebToolsClient
|
||||
|
||||
def __init__(self, client: DeepseekSearchClient, *, model: str) -> None:
|
||||
def __init__(self, client: DeepseekWebToolsClient) -> None:
|
||||
self._client = client
|
||||
self._model = model
|
||||
|
||||
async def search(self, query: str) -> Sequence[SearchHit]:
|
||||
return await self._client.search(query, model=self._model)
|
||||
return await self._client.search(query)
|
||||
|
||||
|
||||
async def search_in_order(
|
||||
@@ -84,7 +83,8 @@ def build_web_search_tool(
|
||||
"""Search the web and return the pages that matched.
|
||||
|
||||
One entry per hit (``title — url``, plus the snippet when the source has
|
||||
one): read a page with ``fetch`` before relying on its content.
|
||||
one): read a page with ``fetch`` (or ``fetch_local`` for local targets)
|
||||
before relying on its content.
|
||||
"""
|
||||
found, failures = await search_in_order(backends, query)
|
||||
if not found:
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Every truncation site (the agent loop's generic tool-result cap,
|
||||
``run_argv`` / ``run_argv_batch`` per-stream caps, ``read_file``,
|
||||
``fetch``, and request-time compact shrinks) appends the same compact marker
|
||||
when output is cut short::
|
||||
``fetch`` / ``fetch_local``, and request-time compact shrinks) appends the same
|
||||
compact marker when output is cut short::
|
||||
|
||||
[Truncated (32000 chars max.; 500 omitted). truncate_token=...]
|
||||
|
||||
@@ -14,9 +14,9 @@ source is exhausted (then no marker is emitted).
|
||||
|
||||
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`` bodies — has 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
|
||||
(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.
|
||||
"""
|
||||
|
||||
@@ -130,6 +130,15 @@ async def test_summarize_messages() -> None:
|
||||
assert "world" in user_msg.content
|
||||
|
||||
|
||||
async def test_summarize_default_prompt_says_tools_are_unavailable() -> None:
|
||||
client = SummaryClient()
|
||||
_ = await summarize_messages(client, [UserChatMessage(content="hi")], model="m")
|
||||
assert client.last is not None
|
||||
sys_msg = client.last.messages[0]
|
||||
assert isinstance(sys_msg, SystemChatMessage)
|
||||
assert "tool calls are unavailable" in sys_msg.content.lower()
|
||||
|
||||
|
||||
async def test_summarize_custom_prompts() -> None:
|
||||
client = SummaryClient()
|
||||
_ = await summarize_messages(
|
||||
|
||||
@@ -14,10 +14,12 @@ from plyngent.agent import (
|
||||
ToolCallEvent,
|
||||
ToolRegistry,
|
||||
ToolResultEvent,
|
||||
TruncatedEvent,
|
||||
UsageEvent,
|
||||
run_chat_loop,
|
||||
tool,
|
||||
)
|
||||
from plyngent.agent.notices import TRUNCATED_NOTICE_KIND, notice_of_kind
|
||||
from plyngent.config.models import DatabaseConfig
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AnyChatMessage,
|
||||
@@ -987,20 +989,115 @@ async def test_empty_completion_raises() -> None:
|
||||
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||
|
||||
|
||||
async def test_length_finish_raises() -> None:
|
||||
async def test_length_finish_keeps_the_partial_answer_and_continues() -> None:
|
||||
client = ScriptedClient(
|
||||
[
|
||||
_response(
|
||||
AssistantChatMessage(content="partial"),
|
||||
finish_reason="length",
|
||||
)
|
||||
_response(AssistantChatMessage(content="partial"), finish_reason="length"),
|
||||
_response(AssistantChatMessage(content="rest")),
|
||||
]
|
||||
)
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
with pytest.raises(RuntimeError, match="truncated"):
|
||||
events = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||
|
||||
truncated = [e for e in events if isinstance(e, TruncatedEvent)]
|
||||
assert len(truncated) == 1
|
||||
assert truncated[0].kept_chars == len("partial")
|
||||
assert truncated[0].continued
|
||||
# The partial answer stays in history under a truncation notice, and the
|
||||
# continuation round is sent that history — the model continues its answer
|
||||
# rather than writing it again from the start.
|
||||
assert isinstance(messages[1], AssistantChatMessage)
|
||||
assert messages[1].content == "partial"
|
||||
assert notice_of_kind(messages[2], TRUNCATED_NOTICE_KIND)
|
||||
assert isinstance(messages[3], AssistantChatMessage)
|
||||
assert messages[3].content == "rest"
|
||||
assert any(notice_of_kind(m, TRUNCATED_NOTICE_KIND) for m in client.calls[1].messages)
|
||||
|
||||
|
||||
async def test_length_finish_stops_once_the_continuation_budget_is_spent() -> None:
|
||||
client = ScriptedClient([_response(AssistantChatMessage(content="partial"), finish_reason="length")] * 2)
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
events = [
|
||||
e
|
||||
async for e in run_chat_loop(
|
||||
client,
|
||||
messages,
|
||||
model="m",
|
||||
stream=False,
|
||||
max_truncation_continues=1,
|
||||
)
|
||||
]
|
||||
|
||||
assert [e.continued for e in events if isinstance(e, TruncatedEvent)] == [True, False]
|
||||
assert len(client.calls) == 2
|
||||
# Nothing raised: the turn ends with the partial answer kept, so `/retry`
|
||||
# continues it instead of regenerating.
|
||||
assert isinstance(messages[-2], AssistantChatMessage)
|
||||
assert messages[-2].content == "partial"
|
||||
assert notice_of_kind(messages[-1], TRUNCATED_NOTICE_KIND)
|
||||
|
||||
|
||||
async def test_length_finish_drops_the_rounds_tool_calls() -> None:
|
||||
ran: list[str] = []
|
||||
|
||||
@tool(name="boom", register=False)
|
||||
async def _boom(argument: str) -> str:
|
||||
ran.append(argument)
|
||||
return "ran"
|
||||
|
||||
call = AssistantFunctionToolCall(
|
||||
id="c1",
|
||||
function=AssistantFunctionTool(name="boom", arguments='{"argument": "x"'),
|
||||
)
|
||||
client = ScriptedClient(
|
||||
[
|
||||
_response(
|
||||
AssistantChatMessage(content="partial", tool_calls=[call]),
|
||||
finish_reason="length",
|
||||
),
|
||||
_response(AssistantChatMessage(content="done")),
|
||||
]
|
||||
)
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
events = [e async for e in run_chat_loop(client, messages, model="m", tools=ToolRegistry([_boom]), stream=False)]
|
||||
|
||||
# A call cut off mid-write is not replayed: the model re-issues it after
|
||||
# reading the notice.
|
||||
assert ran == []
|
||||
assert not [e for e in events if isinstance(e, (ToolCallEvent, ToolResultEvent))]
|
||||
kept = messages[1]
|
||||
assert isinstance(kept, AssistantChatMessage)
|
||||
assert kept.content == "partial"
|
||||
assert kept.tool_calls is UNSET
|
||||
|
||||
|
||||
async def test_length_finish_without_content_still_raises() -> None:
|
||||
client = ScriptedClient([_response(AssistantChatMessage(content=None), finish_reason="length")])
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
with pytest.raises(RuntimeError, match="output limit reached with nothing written"):
|
||||
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||
|
||||
|
||||
async def test_truncated_turn_is_retryable() -> None:
|
||||
store = await MemoryStore.open(DatabaseConfig())
|
||||
session = await store.create_session(name="t")
|
||||
client = ScriptedClient(
|
||||
[
|
||||
_response(AssistantChatMessage(content="partial"), finish_reason="length"),
|
||||
_response(AssistantChatMessage(content="rest")),
|
||||
]
|
||||
)
|
||||
agent = ChatAgent(client, model="m", memory=store, session_id=session.sid, max_truncation_continues=0, stream=False)
|
||||
_ = [e async for e in agent.run("hi")]
|
||||
|
||||
# Kept, not rolled back: the turn is unfinished, so retry continues it.
|
||||
assert agent.pending_retry_text == "hi"
|
||||
_ = [e async for e in agent.retry()]
|
||||
assert any(notice_of_kind(m, TRUNCATED_NOTICE_KIND) for m in client.calls[1].messages)
|
||||
assert agent.pending_retry_text is None
|
||||
await store.close()
|
||||
|
||||
|
||||
async def test_content_filter_finish_raises() -> None:
|
||||
client = ScriptedClient(
|
||||
[
|
||||
|
||||
@@ -2,7 +2,16 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
|
||||
from plyngent.agent import ChatAgent, Notice, aside_notice, format_moment, has_notice, resume_notice, skill_tools_notice
|
||||
from plyngent.agent import (
|
||||
ChatAgent,
|
||||
Notice,
|
||||
aside_notice,
|
||||
format_moment,
|
||||
has_notice,
|
||||
resume_notice,
|
||||
skill_tools_notice,
|
||||
truncated_turn_notice,
|
||||
)
|
||||
from plyngent.agent.chat import incomplete_turn_user_text
|
||||
from plyngent.config.models import DatabaseConfig
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
@@ -124,3 +133,26 @@ def test_trailing_developer_message_keeps_incomplete_turn_detectable() -> None:
|
||||
# A finished turn (trailing assistant) stays non-retryable past a notice.
|
||||
done = [UserChatMessage(content="do it"), AssistantChatMessage(content="ok"), notice]
|
||||
assert incomplete_turn_user_text(done) is None
|
||||
|
||||
|
||||
def test_truncated_turn_notice_keeps_the_partial_answer_retryable() -> None:
|
||||
"""A kept partial answer is unfinished work, so retry continues it."""
|
||||
tail = [
|
||||
UserChatMessage(content="do it"),
|
||||
AssistantChatMessage(content="partial answer"),
|
||||
truncated_turn_notice().to_message(),
|
||||
]
|
||||
assert incomplete_turn_user_text(tail) == "do it"
|
||||
# The same tail without the truncation notice is a finished turn.
|
||||
assert incomplete_turn_user_text(tail[:2]) is None
|
||||
|
||||
|
||||
def test_truncated_turn_notice_tells_the_model_where_it_stopped() -> None:
|
||||
notice = truncated_turn_notice()
|
||||
assert notice.kind == "truncated"
|
||||
assert notice.to_message().content.startswith("[plyngent notice: truncated]")
|
||||
assert "max_tokens" in notice.body
|
||||
# The dropped call and the kept answer are both spelled out.
|
||||
assert "discarded" in notice.body
|
||||
assert "kept in the transcript" in notice.body
|
||||
assert "max_tokens" in notice.summary
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Aggregated soft confirm: one prompt for a round's mutating calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import (
|
||||
BatchConfirmDecision,
|
||||
ToolRegistry,
|
||||
ToolTag,
|
||||
tool,
|
||||
)
|
||||
from plyngent.agent.loop import _execute_tool_calls
|
||||
from plyngent.lmproto.openai_compatible.model import AssistantFunctionTool, AssistantFunctionToolCall
|
||||
from plyngent.tools.context import SessionState, bind_tool_context
|
||||
from plyngent.tools.grants import has_grant
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from plyngent.agent import ToolConfirmRequest
|
||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||
|
||||
|
||||
def _danger(name: str, _args: Mapping[str, object]) -> str | None:
|
||||
return f"mutate {name}"
|
||||
|
||||
|
||||
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] = []
|
||||
single_prompts: list[str] = []
|
||||
|
||||
@tool(tags=ToolTag.LOCAL, register=False)
|
||||
async def stamp(path: str) -> str:
|
||||
return f"stamped {path}"
|
||||
|
||||
def on_batch(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
|
||||
batch_prompts.append(len(requests))
|
||||
return BatchConfirmDecision(allowed=True)
|
||||
|
||||
async def on_single(name: str, _args: Mapping[str, object], _reason: str) -> bool:
|
||||
single_prompts.append(name)
|
||||
return True
|
||||
|
||||
reg = ToolRegistry([stamp], danger=_danger, on_confirm=on_single, on_confirm_batch=on_batch)
|
||||
tool_calls = [
|
||||
AssistantFunctionToolCall(id=f"c{index}", function=AssistantFunctionTool(name="stamp", arguments=arguments))
|
||||
for index, arguments in enumerate(['{"path": "a"}', '{"path": "b"}'])
|
||||
]
|
||||
messages: list[AnyChatMessage] = []
|
||||
|
||||
events = [event async for event in _execute_tool_calls(reg, tool_calls, messages)]
|
||||
|
||||
assert batch_prompts == [2]
|
||||
assert single_prompts == []
|
||||
assert [getattr(message, "content", None) for message in messages] == ["stamped a", "stamped b"]
|
||||
assert len(events) == len(tool_calls) * 2 # call event + result event per call
|
||||
|
||||
|
||||
async def test_round_asks_once_for_many_mutating_calls() -> None:
|
||||
"""Three calls in one round share one prompt instead of three."""
|
||||
ran: list[str] = []
|
||||
batch_prompts: list[list[ToolConfirmRequest]] = []
|
||||
single_prompts: list[str] = []
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
|
||||
async def stamp(path: str) -> str:
|
||||
ran.append(path)
|
||||
return f"stamped {path}"
|
||||
|
||||
def on_batch(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
|
||||
batch_prompts.append(list(requests))
|
||||
return BatchConfirmDecision(allowed=True)
|
||||
|
||||
async def on_single(name: str, _args: Mapping[str, object], _reason: str) -> bool:
|
||||
single_prompts.append(name)
|
||||
return True
|
||||
|
||||
reg = ToolRegistry([stamp], danger=_danger, on_confirm=on_single, on_confirm_batch=on_batch)
|
||||
calls = [("stamp", '{"path": "a"}'), ("stamp", '{"path": "b"}'), ("stamp", '{"path": "c"}')]
|
||||
decisions = await reg.prepare_batch_confirm(calls)
|
||||
|
||||
assert all(decision is not None and decision.allowed for decision in decisions)
|
||||
assert [request.args["path"] for request in batch_prompts[0]] == ["a", "b", "c"]
|
||||
assert single_prompts == []
|
||||
|
||||
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 ran == ["a", "b", "c"]
|
||||
assert single_prompts == []
|
||||
|
||||
|
||||
async def test_single_request_keeps_the_one_call_prompt() -> None:
|
||||
"""One mutating call stays the plain per-call confirm."""
|
||||
batch_prompts: list[int] = []
|
||||
single_prompts: list[str] = []
|
||||
|
||||
@tool(tags=ToolTag.LOCAL, register=False)
|
||||
async def stamp(path: str) -> str:
|
||||
return f"stamped {path}"
|
||||
|
||||
def on_batch(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
|
||||
batch_prompts.append(len(requests))
|
||||
return BatchConfirmDecision(allowed=True)
|
||||
|
||||
async def on_single(name: str, _args: Mapping[str, object], _reason: str) -> bool:
|
||||
single_prompts.append(name)
|
||||
return True
|
||||
|
||||
reg = ToolRegistry([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 single_prompts == ["stamp"]
|
||||
|
||||
|
||||
async def test_calls_that_cannot_run_are_left_out_of_the_prompt() -> None:
|
||||
"""Missing arguments, bad JSON and unknown tools never reach the user."""
|
||||
batch_prompts: list[list[str]] = []
|
||||
|
||||
@tool(tags=ToolTag.LOCAL, register=False)
|
||||
async def stamp(path: str, *, force: bool = False) -> str:
|
||||
return f"stamped {path} ({force})"
|
||||
|
||||
def on_batch(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
|
||||
batch_prompts.append([str(request.args.get("path")) for request in requests])
|
||||
return BatchConfirmDecision(allowed=True)
|
||||
|
||||
reg = ToolRegistry([stamp], danger=_danger, on_confirm_batch=on_batch)
|
||||
decisions = await reg.prepare_batch_confirm(
|
||||
[
|
||||
("stamp", "{}"), # missing ``path``
|
||||
("stamp", "not json"),
|
||||
("nope", "{}"),
|
||||
("stamp", '{"path": "a"}'),
|
||||
("stamp", '{"path": "b", "force": true}'),
|
||||
]
|
||||
)
|
||||
|
||||
assert decisions[:3] == [None, None, None]
|
||||
assert all(decision is not None for decision in decisions[3:])
|
||||
assert batch_prompts == [["a", "b"]]
|
||||
missing = await reg.execute("stamp", "{}")
|
||||
assert missing.startswith("error: `stamp` is missing required argument")
|
||||
|
||||
|
||||
async def test_batch_deny_reaches_every_call_with_one_comment() -> None:
|
||||
"""A deny stops the whole batch and hands the same comment to each call."""
|
||||
ran: list[str] = []
|
||||
|
||||
@tool(tags=ToolTag.LOCAL, register=False)
|
||||
async def stamp(path: str) -> str:
|
||||
ran.append(path)
|
||||
return f"stamped {path}"
|
||||
|
||||
def on_batch(_requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
|
||||
return BatchConfirmDecision(allowed=False, comment="not these two")
|
||||
|
||||
reg = ToolRegistry([stamp], danger=_danger, on_confirm_batch=on_batch)
|
||||
calls = [("stamp", '{"path": "a"}'), ("stamp", '{"path": "b"}')]
|
||||
decisions = await reg.prepare_batch_confirm(calls)
|
||||
|
||||
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("user comment: not these two" in result for result in results)
|
||||
assert ran == []
|
||||
|
||||
|
||||
async def test_each_round_asks_again() -> None:
|
||||
"""An answer covers one round: the next round's calls are asked again."""
|
||||
batch_prompts: list[int] = []
|
||||
|
||||
@tool(tags=ToolTag.LOCAL, register=False)
|
||||
async def stamp(path: str) -> str:
|
||||
return f"stamped {path}"
|
||||
|
||||
def on_batch(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
|
||||
batch_prompts.append(len(requests))
|
||||
return BatchConfirmDecision(allowed=True)
|
||||
|
||||
reg = ToolRegistry([stamp], danger=_danger, on_confirm_batch=on_batch)
|
||||
for first, second in (("a", "b"), ("c", "d")):
|
||||
calls = [("stamp", f'{{"path": "{first}"}}'), ("stamp", f'{{"path": "{second}"}}')]
|
||||
decisions = await reg.prepare_batch_confirm(calls)
|
||||
for index, (name, arguments) in enumerate(calls):
|
||||
await reg.execute(name, arguments, decision=decisions[index])
|
||||
assert batch_prompts == [2, 2]
|
||||
|
||||
|
||||
async def test_manual_hands_the_round_back_to_the_per_call_prompt() -> None:
|
||||
"""``each`` answers nothing for the batch: every call is asked on its own."""
|
||||
batch_prompts: list[int] = []
|
||||
single_prompts: list[str] = []
|
||||
|
||||
@tool(tags=ToolTag.LOCAL, register=False)
|
||||
async def stamp(path: str) -> str:
|
||||
return f"stamped {path}"
|
||||
|
||||
def on_batch(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
|
||||
batch_prompts.append(len(requests))
|
||||
return BatchConfirmDecision(allowed=False, each=True)
|
||||
|
||||
async def on_single(name: str, _args: Mapping[str, object], _reason: str) -> bool:
|
||||
single_prompts.append(name)
|
||||
return True
|
||||
|
||||
reg = ToolRegistry([stamp], danger=_danger, on_confirm=on_single, on_confirm_batch=on_batch)
|
||||
calls = [("stamp", '{"path": "a"}'), ("stamp", '{"path": "b"}')]
|
||||
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 batch_prompts == [2]
|
||||
assert single_prompts == ["stamp", "stamp"]
|
||||
|
||||
|
||||
async def test_without_a_batch_hook_every_call_prompts_on_its_own() -> None:
|
||||
"""A host that installs no batch hook keeps the per-call prompts."""
|
||||
single_prompts: list[str] = []
|
||||
|
||||
@tool(tags=ToolTag.LOCAL, register=False)
|
||||
async def stamp(path: str) -> str:
|
||||
return f"stamped {path}"
|
||||
|
||||
async def on_single(name: str, _args: Mapping[str, object], _reason: str) -> bool:
|
||||
single_prompts.append(name)
|
||||
return True
|
||||
|
||||
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 single_prompts == ["stamp", "stamp"]
|
||||
|
||||
|
||||
async def test_batch_allow_records_the_trustable_grant() -> None:
|
||||
"""A batch allow still carries the trust a single allow would record."""
|
||||
single_prompts: list[str] = []
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.TRUSTABLE, register=False)
|
||||
async def trust_me(path: str) -> str:
|
||||
return f"done {path}"
|
||||
|
||||
def on_batch(_requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
|
||||
return BatchConfirmDecision(allowed=True)
|
||||
|
||||
async def on_single(name: str, _args: Mapping[str, object], _reason: str) -> bool:
|
||||
single_prompts.append(name)
|
||||
return True
|
||||
|
||||
session = SessionState(session_id=1)
|
||||
reg = ToolRegistry(
|
||||
[trust_me],
|
||||
danger=_danger,
|
||||
on_confirm=on_single,
|
||||
on_confirm_batch=on_batch,
|
||||
auto_bind_state=True,
|
||||
session_state=session,
|
||||
)
|
||||
with bind_tool_context(session=session):
|
||||
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}"
|
||||
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 single_prompts == []
|
||||
@@ -2,7 +2,14 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import ErrorEvent, ReasoningDeltaEvent, TextDeltaEvent, ToolCallEvent, ToolResultEvent
|
||||
from plyngent.agent import (
|
||||
ErrorEvent,
|
||||
ReasoningDeltaEvent,
|
||||
TextDeltaEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
TruncatedEvent,
|
||||
)
|
||||
from plyngent.cli.display import (
|
||||
_RUN_BATCH_CMD_LIMIT,
|
||||
_clear_streamed_lines,
|
||||
@@ -537,7 +544,7 @@ async def test_pretty_fetch(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"status=200\nmethod=GET\nfinal_url=https://example.com\ncontent_type=application/json\n"
|
||||
"body_kind=json\nbytes=1229\ntruncated=false\nredirects=0\nsecurity=public\n--- body ---\n{...}\n"
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result(result)]))
|
||||
await render_events(_aiter([_pretty_call("fetch_local", '{"url": "https://example.com"}'), _result(result)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Fetch GET https://example.com (200)" in out
|
||||
assert "[tool]" not in out
|
||||
@@ -548,7 +555,7 @@ async def test_pretty_fetch_error_status(capsys: pytest.CaptureFixture[str]) ->
|
||||
"status=404\nmethod=GET\nfinal_url=https://example.com/nope\ncontent_type=text/html\n"
|
||||
"body_kind=html\nbytes=45\ntruncated=false\nredirects=0\nsecurity=public\n--- body ---\nNot found\n"
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com/nope"}'), _result(result)]))
|
||||
await render_events(_aiter([_pretty_call("fetch_local", '{"url": "https://example.com/nope"}'), _result(result)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Fetch GET https://example.com/nope (404)" in out
|
||||
|
||||
@@ -558,17 +565,37 @@ async def test_pretty_fetch_truncated(capsys: pytest.CaptureFixture[str]) -> Non
|
||||
"status=200\nmethod=GET\nfinal_url=https://example.com\ncontent_type=text/plain\n"
|
||||
"body_kind=text\nbytes=50000\ntruncated=true\nredirects=0\nsecurity=public\n--- body ---\n..."
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result(result)]))
|
||||
await render_events(_aiter([_pretty_call("fetch_local", '{"url": "https://example.com"}'), _result(result)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Fetch GET https://example.com (200)" in out
|
||||
|
||||
|
||||
async def test_pretty_fetch_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(
|
||||
_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result("error: fetch failed: boom")])
|
||||
_aiter(
|
||||
[_pretty_call("fetch_local", '{"url": "https://example.com"}'), _result("error: fetch_local failed: boom")]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Fetch GET https://example.com (error: fetch failed: boom)" in out
|
||||
assert "* Fetch GET https://example.com (error: fetch_local failed: boom)" in out
|
||||
|
||||
|
||||
async def test_pretty_remote_fetch(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
result = "title=Example article\nurl=https://example.com\nchars=1234\ntruncated=false\n--- content ---\n..."
|
||||
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result(result)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Fetch https://example.com (1234 chars)" in out
|
||||
|
||||
|
||||
async def test_pretty_fetch_reads_a_legacy_local_row(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
# Rows written before the local tool was renamed carry the local status block.
|
||||
result = (
|
||||
"status=200\nmethod=GET\nfinal_url=https://example.com\ncontent_type=text/plain\n"
|
||||
"body_kind=text\nbytes=12\ntruncated=false\nredirects=0\nsecurity=public\n--- body ---\nhi\n"
|
||||
)
|
||||
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result(result)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Fetch https://example.com (200)" in out
|
||||
|
||||
|
||||
async def test_pretty_web_search(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
@@ -1257,3 +1284,12 @@ async def test_pretty_prefix_closed_before_error_line(
|
||||
assert "* Read 'a.txt' \n" in out
|
||||
assert "[error] source=tool boom\n" in out
|
||||
assert "* Read 'a.txt' (error)\n" in out
|
||||
|
||||
|
||||
async def test_render_truncated_round(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A round cut off at the output limit says so, and how the turn goes on."""
|
||||
await render_events(_aiter([TruncatedEvent(kept_chars=42, continued=True)]))
|
||||
assert "[limit] output truncated (42 chars kept); continuing\n" in capsys.readouterr().out
|
||||
|
||||
await render_events(_aiter([TruncatedEvent(kept_chars=42, continued=False)]))
|
||||
assert "[limit] output truncated (42 chars kept); /retry continues from here\n" in capsys.readouterr().out
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Aggregated soft-confirm prompt: one box for a round's tool calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from plyngent.agent import BatchConfirmDecision, ToolConfirmRequest
|
||||
from plyngent.cli.limits import (
|
||||
_prompt_confirm_batch_sync,
|
||||
format_batch_confirm_box,
|
||||
format_tool_confirm_box,
|
||||
prompt_confirm_batch_async,
|
||||
)
|
||||
from plyngent.prompting import NonInteractiveBackend, temporary_backend
|
||||
from tests.test_prompting import ScriptedBackend
|
||||
|
||||
|
||||
def _requests() -> list[ToolConfirmRequest]:
|
||||
return [
|
||||
ToolConfirmRequest(name="delete_path", args={"path": "a.txt"}, reason="delete path 'a.txt'"),
|
||||
ToolConfirmRequest(name="move_path", args={"src": "b", "dst": "c"}, reason="move 'b' → 'c'"),
|
||||
]
|
||||
|
||||
|
||||
def test_batch_box_numbers_every_call() -> None:
|
||||
box = format_batch_confirm_box(_requests())
|
||||
lines = box.splitlines()
|
||||
assert "confirm · 2 tool calls" in lines[1]
|
||||
assert any("[delete_path] delete path 'a.txt'" in line for line in lines)
|
||||
assert any("[move_path] move 'b' → 'c'" in line for line in lines)
|
||||
# Numbering keeps the listed order visible when the user answers.
|
||||
assert any(line.startswith("│ 1. ") for line in lines)
|
||||
assert any(line.startswith("│ 2. ") for line in lines)
|
||||
|
||||
|
||||
def test_batch_box_indents_continuation_lines() -> None:
|
||||
box = format_batch_confirm_box(
|
||||
[
|
||||
ToolConfirmRequest(name="run_argv", args={"argv": ["python", "-c", "x"]}, reason="argv\n body"),
|
||||
ToolConfirmRequest(name="delete_path", args={"path": "a"}, reason="delete path 'a'"),
|
||||
]
|
||||
)
|
||||
assert any(line.startswith("│ ") and "body" in line for line in box.splitlines())
|
||||
|
||||
|
||||
def test_single_call_box_is_unchanged() -> None:
|
||||
box = format_tool_confirm_box("delete_path", "delete path 'a.txt'")
|
||||
assert "confirm · tool 'delete_path'" in box.splitlines()[1]
|
||||
|
||||
|
||||
def test_batch_prompt_allows_every_call_on_yes() -> None:
|
||||
with temporary_backend(ScriptedBackend(["y"])):
|
||||
decision = _prompt_confirm_batch_sync(_requests())
|
||||
assert decision == BatchConfirmDecision(allowed=True)
|
||||
|
||||
|
||||
def test_batch_prompt_hands_the_round_to_the_per_call_prompt_on_manual() -> None:
|
||||
with temporary_backend(ScriptedBackend(["m"])):
|
||||
decision = _prompt_confirm_batch_sync(_requests())
|
||||
assert decision.each is True
|
||||
assert decision.allowed is False
|
||||
|
||||
|
||||
def test_batch_prompt_denies_with_a_comment() -> None:
|
||||
with temporary_backend(ScriptedBackend(["", "keep those files"])):
|
||||
decision = _prompt_confirm_batch_sync(_requests())
|
||||
assert decision.allowed is False
|
||||
assert decision.comment == "keep those files"
|
||||
|
||||
|
||||
def test_batch_prompt_defaults_to_deny() -> None:
|
||||
with temporary_backend(ScriptedBackend([])):
|
||||
decision = _prompt_confirm_batch_sync(_requests())
|
||||
assert decision == BatchConfirmDecision(allowed=False)
|
||||
|
||||
|
||||
def test_batch_prompt_denies_when_not_interactive() -> None:
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
decision = _prompt_confirm_batch_sync(_requests())
|
||||
assert decision == BatchConfirmDecision(allowed=False)
|
||||
|
||||
|
||||
async def test_batch_prompt_async_allows() -> None:
|
||||
with temporary_backend(ScriptedBackend(["yes"])):
|
||||
decision = await prompt_confirm_batch_async(_requests())
|
||||
assert decision == BatchConfirmDecision(allowed=True)
|
||||
@@ -7,7 +7,7 @@ from dataclasses import replace
|
||||
from plyngent.config.models import DeepseekProvider
|
||||
from plyngent.config.routing import (
|
||||
EffectiveProvider,
|
||||
deepseek_search_base_url,
|
||||
deepseek_api_base_url,
|
||||
local_web_search,
|
||||
resolve_effective_provider,
|
||||
wants_web_search,
|
||||
@@ -46,8 +46,14 @@ def test_chat_and_responses_conventions_use_the_local_tool() -> None:
|
||||
assert local_web_search(_effective(convention="anthropic")) is False
|
||||
|
||||
|
||||
def test_without_provider_tools_there_is_no_search() -> None:
|
||||
assert local_web_search(_effective(convention="responses", tools=[])) is False
|
||||
def test_provider_tools_only_gate_the_hosted_search() -> None:
|
||||
# ``search_sources`` owns the local tool; ``provider_tools`` owns the hosted
|
||||
# one. Turning hosted tools off must not take local search with it.
|
||||
assert local_web_search(_effective(convention="responses", tools=[])) is True
|
||||
assert local_web_search(_effective(convention="anthropic", tools=[])) is True
|
||||
# With hosted tools on, the Anthropic surface keeps the hosted search and
|
||||
# the local tool stays out of the way.
|
||||
assert local_web_search(_effective(convention="anthropic")) is False
|
||||
|
||||
|
||||
def test_without_search_sources_there_is_no_local_tool() -> None:
|
||||
@@ -55,12 +61,20 @@ def test_without_search_sources_there_is_no_local_tool() -> None:
|
||||
# for a search (the hosted path on the Anthropic convention is unaffected).
|
||||
assert _effective(convention="responses", sources=[]).search_sources == []
|
||||
assert local_web_search(_effective(convention="responses", sources=[])) is False
|
||||
assert _effective(convention="responses").search_sources == ["deepseek", "bing"]
|
||||
assert _effective(convention="responses").search_sources == ["deepseek"]
|
||||
|
||||
|
||||
def test_search_base_url_prefers_an_anthropic_endpoint() -> None:
|
||||
assert deepseek_search_base_url(_effective(convention="responses")) == "https://api.deepseek.com/anthropic"
|
||||
def test_api_base_url_drops_the_convention_and_version_paths() -> None:
|
||||
# The Search API appends ``/v1/tools/search`` itself, so neither the
|
||||
# Anthropic convention's ``/anthropic`` nor an OpenAI-compatible ``/v1``
|
||||
# may survive in the base.
|
||||
assert deepseek_api_base_url(_effective(convention="responses")) == "https://api.deepseek.com"
|
||||
assert deepseek_api_base_url(_effective(convention="anthropic")) == "https://api.deepseek.com"
|
||||
assert (
|
||||
deepseek_search_base_url(_effective(convention="responses", url="https://gateway.example/anthropic"))
|
||||
== "https://gateway.example/anthropic"
|
||||
deepseek_api_base_url(_effective(convention="anthropic", url="https://gateway.example/api/anthropic"))
|
||||
== "https://gateway.example/api"
|
||||
)
|
||||
assert (
|
||||
deepseek_api_base_url(_effective(convention="responses", url="https://gateway.example/v1"))
|
||||
== "https://gateway.example"
|
||||
)
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
"""DeepSeek server-side search backend (Anthropic-compatible endpoint)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import msgspec
|
||||
import pytest
|
||||
|
||||
from plyngent.lmproto.anthropic.config import AnthropicConfig
|
||||
from plyngent.lmproto.deepseek.anthropic.search import (
|
||||
DeepseekSearchClient,
|
||||
SearchHit,
|
||||
hits_from_content,
|
||||
)
|
||||
|
||||
|
||||
def test_hits_from_content_dedupes_and_skips_noise() -> None:
|
||||
content = [
|
||||
{"type": "thinking", "thinking": "search"},
|
||||
{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": "call_1",
|
||||
"content": [
|
||||
{"type": "web_search_result", "title": "First", "url": "https://a.example/x"},
|
||||
{"type": "web_search_result", "title": "First again", "url": "https://a.example/x"},
|
||||
{"type": "web_search_result", "url": "https://b.example/y"},
|
||||
{"type": "web_search_result", "title": "no url"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": "call_2",
|
||||
"content": [{"type": "web_search_tool_result_error", "error_code": "max_uses_exceeded"}],
|
||||
},
|
||||
]
|
||||
hits = hits_from_content(content)
|
||||
assert hits == [
|
||||
SearchHit(title="First", url="https://a.example/x"),
|
||||
SearchHit(title="", url="https://b.example/y"),
|
||||
]
|
||||
assert hits_from_content("not a list") == []
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status_code: int, body: bytes) -> None:
|
||||
self.status_code = status_code
|
||||
self.content = body
|
||||
|
||||
|
||||
def _search_body() -> bytes:
|
||||
return msgspec.json.encode(
|
||||
{
|
||||
"id": "msg_1",
|
||||
"content": [
|
||||
{
|
||||
"type": "server_tool_use",
|
||||
"id": "call_00_1",
|
||||
"name": "web_search",
|
||||
"input": {"query": "DeepSeek-V4"},
|
||||
},
|
||||
{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": "call_00_1",
|
||||
"content": [
|
||||
{
|
||||
"type": "web_search_result",
|
||||
"title": "DeepSeek-V4 preview",
|
||||
"url": "https://api-docs.deepseek.com/news/news260424/",
|
||||
"encrypted_content": "opaque",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
"usage": {"input_tokens": 9932, "output_tokens": 377},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_sends_the_hosted_tool_and_reads_its_hits(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = DeepseekSearchClient(AnthropicConfig(api_key="sk-test", base_url="https://api.deepseek.com/anthropic"))
|
||||
sent: dict[str, object] = {}
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
sent["path"] = path
|
||||
sent.update(kwargs)
|
||||
return _Resp(200, _search_body())
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
hits = await client.search("DeepSeek-V4", model="deepseek-flash")
|
||||
|
||||
assert hits == [SearchHit(title="DeepSeek-V4 preview", url="https://api-docs.deepseek.com/news/news260424/")]
|
||||
assert sent["path"] == "/messages"
|
||||
assert sent["stream"] is False
|
||||
data = sent["data"]
|
||||
assert isinstance(data, bytes)
|
||||
payload = msgspec.json.decode(data, type=dict[str, object])
|
||||
assert payload["tools"] == [{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}]
|
||||
# A search turn stops as soon as it has searched; the answer is not needed.
|
||||
assert payload["max_tokens"] == 512
|
||||
assert payload["messages"] == [{"role": "user", "content": "DeepSeek-V4"}]
|
||||
headers = sent["headers"]
|
||||
assert isinstance(headers, dict)
|
||||
assert headers["x-api-key"] == "sk-test"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_raises_with_the_endpoint_detail(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = DeepseekSearchClient(AnthropicConfig(api_key="sk-test", base_url="https://api.deepseek.com/anthropic"))
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
return _Resp(422, b'{"error": {"message": "tools[0]: unknown variant"}}')
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
with pytest.raises(RuntimeError, match="unknown variant"):
|
||||
_ = await client.search("DeepSeek-V4", model="deepseek-flash")
|
||||
@@ -0,0 +1,246 @@
|
||||
"""DeepSeek's Search and Fetch APIs (``/v1/tools/search``, ``/v1/tools/fetch``)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import msgspec
|
||||
import pytest
|
||||
|
||||
from plyngent.lmproto.deepseek.webtools import (
|
||||
DeepseekWebToolsClient,
|
||||
SearchRequest,
|
||||
)
|
||||
from plyngent.lmproto.openai_compatible.config import OpenAIConfig
|
||||
from plyngent.lmproto.search import SearchHit
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status_code: int, body: bytes) -> None:
|
||||
self.status_code = status_code
|
||||
self.content = body
|
||||
|
||||
|
||||
def _body(*results: dict[str, object]) -> bytes:
|
||||
return msgspec.json.encode({"results": list(results)})
|
||||
|
||||
|
||||
def _client() -> DeepseekWebToolsClient:
|
||||
return DeepseekWebToolsClient(
|
||||
OpenAIConfig(access_key_or_token="sk-test", base_url="https://api.deepseek.com"),
|
||||
)
|
||||
|
||||
|
||||
def _decode_request(data: object) -> dict[str, object]:
|
||||
assert isinstance(data, bytes)
|
||||
return msgspec.json.decode(data, type=dict[str, object])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_posts_the_query_and_maps_the_hits(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _client()
|
||||
sent: dict[str, object] = {}
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
sent["path"] = path
|
||||
sent.update(kwargs)
|
||||
return _Resp(
|
||||
200,
|
||||
_body(
|
||||
{
|
||||
"rerank_score": 2.4,
|
||||
"title": " DeepSeek V4 released ",
|
||||
"url": " https://example.com/v4 ",
|
||||
"snippet": "Tool calling and long context.",
|
||||
"summary": "A longer summary.",
|
||||
"authority": 3,
|
||||
},
|
||||
{"title": "Unusable", "url": ""},
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
hits = await client.search("DeepSeek V4", count=5)
|
||||
|
||||
assert hits == [
|
||||
SearchHit(
|
||||
title="DeepSeek V4 released",
|
||||
url="https://example.com/v4",
|
||||
snippet="Tool calling and long context.",
|
||||
)
|
||||
]
|
||||
assert sent["path"] == "/v1/tools/search"
|
||||
assert sent["stream"] is False
|
||||
payload = _decode_request(sent["data"])
|
||||
assert payload == {"query": "DeepSeek V4", "count": 5}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_stands_in_when_the_index_has_no_snippet(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _client()
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
return _Resp(
|
||||
200,
|
||||
_body(
|
||||
{
|
||||
"title": "T",
|
||||
"url": "https://example.com/1",
|
||||
"snippet": " ",
|
||||
"summary": " A 600 character\tsummary\nspread over lines. ",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
hits = await client.search("q")
|
||||
assert hits == [
|
||||
SearchHit(title="T", url="https://example.com/1", snippet="A 600 character summary spread over lines.")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_summaries_are_cut_for_the_tool_line(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _client()
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
return _Resp(200, _body({"title": "T", "url": "https://example.com/1", "summary": "x" * 500}))
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
hits = await client.search("q")
|
||||
assert len(hits[0].snippet) == 200
|
||||
assert hits[0].snippet.endswith("…")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_results_are_a_normal_answer(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _client()
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
return _Resp(200, _body())
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
assert await client.search("nothing matches this") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_is_clamped_to_the_documented_range(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _client()
|
||||
seen: list[dict[str, object]] = []
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
seen.append(_decode_request(kwargs["data"]))
|
||||
return _Resp(200, _body())
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
_ = await client.search("q", count=0)
|
||||
_ = await client.search("q", count=99)
|
||||
assert [payload["count"] for payload in seen] == [1, 50]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_oversized_query_is_refused_before_the_request(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _client()
|
||||
calls: list[object] = []
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
calls.append(path)
|
||||
return _Resp(200, _body())
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
with pytest.raises(ValueError, match="at most 128 characters"):
|
||||
_ = await client.search("x" * 129)
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_endpoint_error_detail_is_raised(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _client()
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
return _Resp(400, b'{"error": {"message": "\'count\' must be between 1 and 50"}}')
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
with pytest.raises(RuntimeError, match="must be between 1 and 50"):
|
||||
_ = await client.search("q")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_retryable_status_is_tried_again(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _client()
|
||||
monkeypatch.setattr("plyngent.lmproto.deepseek.webtools.client.RETRY_DELAYS", (0.0,))
|
||||
statuses = [429, 200]
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
return _Resp(statuses.pop(0), _body({"title": "T", "url": "https://example.com/1"}))
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
hits = await client.search("q")
|
||||
assert [hit.url for hit in hits] == ["https://example.com/1"]
|
||||
assert statuses == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_balance_error_is_not_retried(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _client()
|
||||
monkeypatch.setattr("plyngent.lmproto.deepseek.webtools.client.RETRY_DELAYS", (0.0,))
|
||||
calls: list[int] = []
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
calls.append(1)
|
||||
return _Resp(402, b'{"error": {"message": "Insufficient Balance"}}')
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
with pytest.raises(RuntimeError, match="Insufficient Balance"):
|
||||
_ = await client.search("q")
|
||||
assert calls == [1]
|
||||
|
||||
|
||||
def test_the_request_model_omits_what_was_not_set() -> None:
|
||||
encoded = msgspec.json.encode(SearchRequest(query="q"))
|
||||
assert msgspec.json.decode(encoded, type=dict[str, object]) == {"query": "q"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_posts_the_url_and_returns_the_page(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _client()
|
||||
sent: dict[str, object] = {}
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
sent["path"] = path
|
||||
sent.update(kwargs)
|
||||
return _Resp(200, msgspec.json.encode({"title": "Example article", "content": "# Article\n\nBody."}))
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
page = await client.fetch("https://example.com/article")
|
||||
|
||||
assert page.title == "Example article"
|
||||
assert page.content == "# Article\n\nBody."
|
||||
assert sent["path"] == "/v1/tools/fetch"
|
||||
assert _decode_request(sent["data"]) == {"url": "https://example.com/article"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_rejects_a_url_the_endpoint_would_refuse(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _client()
|
||||
calls: list[object] = []
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
calls.append(path)
|
||||
return _Resp(200, b"{}")
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
for url in ("file:///etc/passwd", "https://user:secret@example.com/", "https:///no-host", ""):
|
||||
with pytest.raises(ValueError):
|
||||
_ = await client.fetch(url)
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_surfaces_a_security_policy_rejection(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _client()
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
return _Resp(403, b'{"error": {"message": "target rejected by security policy"}}')
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
with pytest.raises(RuntimeError, match="security policy"):
|
||||
_ = await client.fetch("https://example.com/blocked")
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.lmproto.deepseek.anthropic.search import SearchHit
|
||||
from plyngent.lmproto.search import SearchHit
|
||||
from plyngent.tools.net.bing import parse_bing_rss
|
||||
|
||||
DOCUMENT = """<?xml version="1.0" encoding="utf-8" ?>
|
||||
|
||||
@@ -74,7 +74,7 @@ def test_read_only_classification() -> None:
|
||||
"vcs_diff",
|
||||
"vcs_log",
|
||||
"vcs_branch",
|
||||
"fetch",
|
||||
"fetch_local",
|
||||
"skill_list",
|
||||
"skill_read",
|
||||
"skill_search",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for tools.net.fetch (policy, UA, methods, private grants)."""
|
||||
"""Tests for tools.net.fetch_local (policy, UA, methods, private grants)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,7 +10,7 @@ import pytest
|
||||
|
||||
from plyngent.tools.context import read_only_context
|
||||
from plyngent.tools.danger import classify_danger
|
||||
from plyngent.tools.net import fetch, grant_private_host
|
||||
from plyngent.tools.net import fetch_local, grant_private_host
|
||||
from plyngent.tools.net.policy import (
|
||||
DEFAULT_USER_AGENT,
|
||||
FetchPolicyError,
|
||||
@@ -30,7 +30,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
"""Minimal echo server for fetch tests."""
|
||||
"""Minimal echo server for fetch_local tests."""
|
||||
|
||||
@override
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
@@ -171,10 +171,10 @@ def test_soft_confirm_reason_matrix() -> None:
|
||||
|
||||
|
||||
def test_classify_danger_fetch() -> None:
|
||||
assert classify_danger("fetch", {"url": "https://example.com/", "method": "GET"}) is None
|
||||
reason = classify_danger("fetch", {"url": "http://example.com/", "method": "GET"})
|
||||
assert classify_danger("fetch_local", {"url": "https://example.com/", "method": "GET"}) is None
|
||||
reason = classify_danger("fetch_local", {"url": "http://example.com/", "method": "GET"})
|
||||
assert reason is not None and "cleartext" in reason
|
||||
reason2 = classify_danger("fetch", {"url": "https://example.com/api", "method": "DELETE"})
|
||||
reason2 = classify_danger("fetch_local", {"url": "https://example.com/api", "method": "DELETE"})
|
||||
assert reason2 is not None and "DELETE" in reason2
|
||||
|
||||
|
||||
@@ -184,36 +184,36 @@ async def test_fetch_get_post_put_delete(workspace: Path, http_server: str) -> N
|
||||
# Grant loopback for this process/instance (fixture binds InstanceState).
|
||||
grant_private_host("127.0.0.1", int(base.rsplit(":", 1)[1]))
|
||||
|
||||
out = await call_async(fetch, f"{base}/hello")
|
||||
out = await call_async(fetch_local, f"{base}/hello")
|
||||
assert "status=200" in out
|
||||
assert "GET /hello" in out
|
||||
assert "security=cleartext-http" in out
|
||||
|
||||
out_post = await call_async(fetch, f"{base}/echo", method="POST", body="hi")
|
||||
out_post = await call_async(fetch_local, f"{base}/echo", method="POST", body="hi")
|
||||
assert "status=201" in out_post
|
||||
assert "POST:hi" in out_post
|
||||
|
||||
out_put = await call_async(fetch, f"{base}/echo", method="PUT", body="x")
|
||||
out_put = await call_async(fetch_local, f"{base}/echo", method="PUT", body="x")
|
||||
assert "PUT:x" in out_put
|
||||
|
||||
out_del = await call_async(fetch, f"{base}/x", method="DELETE")
|
||||
out_del = await call_async(fetch_local, f"{base}/x", method="DELETE")
|
||||
assert "status=204" in out_del
|
||||
|
||||
|
||||
async def test_fetch_read_only_context_blocks_non_get(workspace: Path, http_server: str) -> None:
|
||||
"""In a read-only context fetch only allows GET; outside it POST works."""
|
||||
"""In a read-only context fetch_local only allows GET; outside it POST works."""
|
||||
del workspace
|
||||
port = int(http_server.rsplit(":", 1)[1])
|
||||
grant_private_host("127.0.0.1", port)
|
||||
|
||||
with read_only_context():
|
||||
out = await call_async(fetch, f"{http_server}/hello")
|
||||
out = await call_async(fetch_local, f"{http_server}/hello")
|
||||
assert "status=200" in out # GET is fine in a read-only context
|
||||
blocked = await call_async(fetch, f"{http_server}/echo", method="POST", body="hi")
|
||||
blocked = await call_async(fetch_local, f"{http_server}/echo", method="POST", body="hi")
|
||||
assert blocked.startswith("error:")
|
||||
assert "GET" in blocked
|
||||
|
||||
out_post = await call_async(fetch, f"{http_server}/echo", method="POST", body="hi")
|
||||
out_post = await call_async(fetch_local, f"{http_server}/echo", method="POST", body="hi")
|
||||
assert "POST:hi" in out_post
|
||||
|
||||
|
||||
@@ -222,18 +222,18 @@ async def test_fetch_user_agent_passthrough(workspace: Path, http_server: str) -
|
||||
port = int(http_server.rsplit(":", 1)[1])
|
||||
grant_private_host("127.0.0.1", port)
|
||||
|
||||
out = await call_async(fetch, f"{http_server}/ua", user_agent="AgentUA/9")
|
||||
out = await call_async(fetch_local, f"{http_server}/ua", user_agent="AgentUA/9")
|
||||
assert "AgentUA/9" in out
|
||||
|
||||
out2 = await call_async(
|
||||
fetch,
|
||||
fetch_local,
|
||||
f"{http_server}/ua",
|
||||
headers={"User-Agent": "HeaderUA/1"},
|
||||
)
|
||||
assert "HeaderUA/1" in out2
|
||||
|
||||
out3 = await call_async(
|
||||
fetch,
|
||||
fetch_local,
|
||||
f"{http_server}/ua",
|
||||
headers={"User-Agent": "HeaderUA/1"},
|
||||
user_agent="ArgUA/2",
|
||||
@@ -247,11 +247,11 @@ async def test_fetch_truncation_and_binary(workspace: Path, http_server: str) ->
|
||||
port = int(http_server.rsplit(":", 1)[1])
|
||||
grant_private_host("127.0.0.1", port)
|
||||
|
||||
out = await call_async(fetch, f"{http_server}/big", max_bytes=100)
|
||||
out = await call_async(fetch_local, f"{http_server}/big", max_bytes=100)
|
||||
assert "truncated=true" in out
|
||||
assert "bytes=100" in out
|
||||
|
||||
out_bin = await call_async(fetch, f"{http_server}/bin")
|
||||
out_bin = await call_async(fetch_local, f"{http_server}/bin")
|
||||
assert "body_kind=binary" in out_bin
|
||||
assert "binary body omitted" in out_bin
|
||||
|
||||
@@ -261,7 +261,7 @@ async def test_fetch_char_truncation_embeds_token(workspace: Path, http_server:
|
||||
port = int(http_server.rsplit(":", 1)[1])
|
||||
grant_private_host("127.0.0.1", port)
|
||||
|
||||
out = await call_async(fetch, f"{http_server}/big", max_chars=1000)
|
||||
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
|
||||
@@ -281,7 +281,7 @@ async def test_fetch_offset_skips_body(workspace: Path, http_server: str) -> Non
|
||||
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, f"{http_server}/big", max_chars=200, offset=4900)
|
||||
out = await call_async(fetch_local, f"{http_server}/big", max_chars=200, offset=4900)
|
||||
body = out.split("--- body ---", 1)[-1]
|
||||
assert body.strip() == "x" * 100
|
||||
assert "truncate_token=" not in out
|
||||
@@ -290,21 +290,21 @@ async def test_fetch_offset_skips_body(workspace: Path, http_server: str) -> Non
|
||||
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).
|
||||
out = await call_async(fetch, f"{http_server}/hello")
|
||||
out = await call_async(fetch_local, f"{http_server}/hello")
|
||||
assert out.startswith("error:")
|
||||
assert "private" in out or "loopback" in out
|
||||
|
||||
|
||||
async def test_fetch_forbidden_metadata(workspace: Path) -> None:
|
||||
del workspace
|
||||
out = await call_async(fetch, "http://169.254.169.254/latest/meta-data/")
|
||||
out = await call_async(fetch_local, "http://169.254.169.254/latest/meta-data/")
|
||||
assert out.startswith("error:")
|
||||
assert "forbidden" in out
|
||||
|
||||
|
||||
async def test_fetch_bad_scheme(workspace: Path) -> None:
|
||||
del workspace
|
||||
out = await call_async(fetch, "file:///etc/passwd")
|
||||
out = await call_async(fetch_local, "file:///etc/passwd")
|
||||
assert out.startswith("error:")
|
||||
assert "scheme" in out
|
||||
|
||||
@@ -313,6 +313,6 @@ async def test_fetch_redirect_loop_capped(workspace: Path, http_server: str) ->
|
||||
del workspace
|
||||
port = int(http_server.rsplit(":", 1)[1])
|
||||
grant_private_host("127.0.0.1", port)
|
||||
out = await call_async(fetch, f"{http_server}/redirect-loop", max_redirects=3)
|
||||
out = await call_async(fetch_local, f"{http_server}/redirect-loop", max_redirects=3)
|
||||
assert out.startswith("error:")
|
||||
assert "redirect" in out.lower()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""The remote ``fetch`` tool (DeepSeek's Fetch API, Markdown pages)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.agent.tools import ToolTag
|
||||
from plyngent.lmproto.deepseek.webtools import FetchResponse
|
||||
from plyngent.tools.net.fetch_remote import FETCH_TOOL_NAME, build_fetch_tool
|
||||
|
||||
|
||||
class _Client:
|
||||
"""Stands in for DeepseekWebToolsClient.fetch."""
|
||||
|
||||
def __init__(self, *, page: FetchResponse | None = None, error: str | None = None) -> None:
|
||||
self.page = page or FetchResponse(title="T", content="body")
|
||||
self.error = error
|
||||
self.urls: list[str] = []
|
||||
|
||||
async def fetch(self, url: str) -> FetchResponse:
|
||||
self.urls.append(url)
|
||||
if self.error is not None:
|
||||
raise RuntimeError(self.error)
|
||||
return self.page
|
||||
|
||||
|
||||
def _tool(client: _Client, *, max_chars: int = 32_000):
|
||||
return build_fetch_tool(client, default_max_chars=max_chars)
|
||||
|
||||
|
||||
def test_definition_shape() -> None:
|
||||
definition = _tool(_Client())
|
||||
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 definition.parameters["required"] == ["url"]
|
||||
assert "fetch_local" in definition.description
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_renders_title_url_and_content() -> None:
|
||||
client = _Client(page=FetchResponse(title=" Example article ", content="# Heading\n\nBody."))
|
||||
result = await _tool(client).handler(url="https://example.com/article")
|
||||
|
||||
assert client.urls == ["https://example.com/article"]
|
||||
assert result.splitlines() == [
|
||||
"title=Example article",
|
||||
"url=https://example.com/article",
|
||||
"chars=16",
|
||||
"truncated=false",
|
||||
"--- content ---",
|
||||
"# Heading",
|
||||
"",
|
||||
"Body.",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_offset_and_truncation_keep_the_remainder_resumable() -> None:
|
||||
client = _Client(page=FetchResponse(title="", content="0123456789"))
|
||||
result = await _tool(client, max_chars=3).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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_reports_a_client_error() -> None:
|
||||
result = await _tool(_Client(error="HTTP 404: no usable content")).handler(url="https://example.com/x")
|
||||
assert result == "error: fetch failed: HTTP 404: no usable content"
|
||||
@@ -86,9 +86,9 @@ async def test_get_truncated_file_missing(workspace: Path) -> None:
|
||||
|
||||
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 import format_fetch_result
|
||||
from plyngent.tools.net.fetch_local import format_fetch_result
|
||||
|
||||
fetch_module = importlib.import_module("plyngent.tools.net.fetch")
|
||||
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"
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.agent.tools import ToolTag
|
||||
from plyngent.lmproto.deepseek.anthropic.search import SearchHit
|
||||
from plyngent.lmproto.search import SearchHit
|
||||
from plyngent.tools.net.search import (
|
||||
WEB_SEARCH_TOOL_NAME,
|
||||
build_web_search_tool,
|
||||
@@ -93,25 +91,21 @@ async def test_handler_without_sources_says_so() -> None:
|
||||
assert "no source configured" in await definition.handler(query="q")
|
||||
|
||||
|
||||
def test_deepseek_backend_passes_the_model_through() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepseek_backend_asks_the_search_api() -> None:
|
||||
from plyngent.tools.net.search import DeepseekSearchBackend
|
||||
|
||||
class _Client:
|
||||
def __init__(self) -> None:
|
||||
self.seen: list[tuple[str, str]] = []
|
||||
self.queries: list[str] = []
|
||||
|
||||
async def search(self, query: str, *, model: str, **kwargs: object) -> list[SearchHit]:
|
||||
self.seen.append((query, model))
|
||||
async def search(self, query: str) -> list[SearchHit]:
|
||||
self.queries.append(query)
|
||||
return _hits(("T", "https://example.com"))
|
||||
|
||||
client: Any = _Client()
|
||||
backend = DeepseekSearchBackend(client, model="deepseek-flash")
|
||||
client = _Client()
|
||||
backend = DeepseekSearchBackend(client) # type: ignore[arg-type] — structural stub
|
||||
|
||||
async def run() -> list[SearchHit]:
|
||||
return list(await backend.search("q"))
|
||||
|
||||
import asyncio
|
||||
|
||||
hits = asyncio.run(run())
|
||||
hits = await backend.search("q")
|
||||
assert [hit.url for hit in hits] == ["https://example.com"]
|
||||
assert client.seen == [("q", "deepseek-flash")]
|
||||
assert client.queries == ["q"]
|
||||
|
||||
Reference in New Issue
Block a user