mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0aba700e2 | |||
| 729627eee9 | |||
| 78b764f70e | |||
| 0dd70ff31b | |||
| 86596633e8 | |||
| f4c0c45403 | |||
| f5cff9d90d | |||
| eef1d3cd29 | |||
| 9742a1ae7a | |||
| cfe36b024b | |||
| 37e130a7dc | |||
| cb19652b2a | |||
| 7a4205a588 |
@@ -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`.
|
||||
@@ -83,7 +87,7 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
||||
- **`@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). 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.
|
||||
- **`/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`)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.1"
|
||||
version = "0.10.0"
|
||||
description = "LLM chat and agent toolkit"
|
||||
authors = [
|
||||
{ name = "HivertMoZara", email = "worldmozara@163.com" },
|
||||
|
||||
@@ -28,8 +28,11 @@ 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
|
||||
|
||||
@@ -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 — \
|
||||
|
||||
@@ -64,7 +64,7 @@ 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]]
|
||||
@@ -89,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
|
||||
@@ -124,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
|
||||
|
||||
@@ -115,8 +115,8 @@ def truncated_turn_notice() -> Notice:
|
||||
_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
|
||||
|
||||
@@ -452,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", "")
|
||||
@@ -888,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"),
|
||||
|
||||
+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(
|
||||
|
||||
@@ -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 == []
|
||||
@@ -544,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
|
||||
@@ -555,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
|
||||
|
||||
@@ -565,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:
|
||||
|
||||
@@ -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