55 Commits

Author SHA1 Message Date
NCBM a89543fd35 release: plyngent 0.8.0 2026-10-07 00:51:49 +08:00
NCBM 9296242881 ci/lint: ignore PLR0917 like PLR0913 2026-10-07 00:47:56 +08:00
NCBM 5a7bd55f2d lmproto: keep None last in the optional unions 2026-10-07 00:47:20 +08:00
NCBM c56449bfce deps/dev: upgrade ruff to 0.16.10 and basedpyright to 1.40.2 2026-10-07 00:47:03 +08:00
NCBM 9fc74e1866 deps: refresh the lock (msgspec 0.22, sqlalchemy 2.1, niquests 3.21.2) 2026-10-07 00:43:54 +08:00
NCBM 6e8368c169 docs: record the complexity gate and the nested-function counting 2026-10-07 00:40:31 +08:00
NCBM e0e19fde8f ci/lint: drop PLR0912 as a duplicate of C901 2026-10-07 00:40:08 +08:00
NCBM 9fac1ecfc0 core/agent+cli: give each C901 exemption a reason 2026-10-07 00:39:30 +08:00
NCBM 432b703b54 docs: describe what a repeated reasoning text does
The ``responses_bridge`` bullet records that ``response.reasoning_text.done`` repeats the fragments: the loop streams each part once, and the round's message still carries the whole chain of thought back to the API.
2026-10-07 00:26:53 +08:00
NCBM bf8203ae37 core/agent: stream a repeated reasoning text only once
DeepSeek's Responses stream sends ``response.reasoning_text.delta`` fragments and then ``response.reasoning_text.done`` carrying the whole chain of thought those fragments spelled out. The loop folded a full delta into the round — it replaces the fragments, and that is what the assistant message hands back to the API — but streamed every delta verbatim, so the chain of thought was printed twice: once as it arrived and again as the ``.done`` text.

A full delta now streams only a tail the fragments did not cover, and nothing at all when it merely repeats them; a round whose fragments never arrived still streams the full text once. The message keeps the complete text either way.
2026-10-07 00:26:38 +08:00
NCBM 4b5f67f5f3 cli/display: render web_search as a pretty line
``web_search`` is composed by the CLI host rather than the builtin catalog, so it had no ``_PRETTY_LINES`` entry and fell back to the ``[tool] …`` / ``[tool ok] …`` whole lines. It now leads with ``* Web Search '<query>' `` (the query as a 60-character preview, like the ask and skill subjects) and lands on the hit count — or on ``(no results)`` when every source came up empty, an emptiness the tool reports as ``error: the search found nothing`` and which is therefore not shown as a failure. A real error still prints in red with its message.

The guard test that keeps a builtin tool from shipping without a renderer now checks this host-composed tool too, since a catalog walk cannot see it.
2026-10-07 00:20:41 +08:00
NCBM 933d5385b9 docs: describe the search sources
The ``web_search`` bullet lists both sources, the fallback order and the
snippet, and says the tool needs a non-empty ``search_sources``.
2026-10-07 00:09:52 +08:00
NCBM 2bb6e06171 core/tools: search a list of sources, DeepSeek's index then Bing
``search_sources`` (a provider list, ``["deepseek", "bing"]`` by default,
``[]`` to not offer the tool at all) names what the local ``web_search`` tool
searches with, in fallback order: ``search_in_order`` asks each source in turn
and keeps the first answer, so a source that errors — a rate limit, a rejected
key — or finds nothing hands over to the next instead of failing the tool. The
handler reports the failures only when every source came up empty.

The Bing leg answers with its RSS results (``?format=rss``), which need no key
and carry a description — the snippet DeepSeek's own index does not hand out —
and it goes through the ``fetch`` client, so the SSRF and host-grant policy
applies to it too (a host that binds tool instance state, as the CLI does).
2026-10-07 00:09:46 +08:00
NCBM 19ab86f590 docs: say which presets ask for provider_tools
The provider-tools bullet names DeepSeek next to OpenAI, since the default and
the effective-provider gate now cover both.
2026-10-06 23:59:07 +08:00
NCBM e877df9d94 config: default DeepSeek to web_search and honour its provider_tools
``provider_tools`` reached the effective provider only for the OpenAI preset,
so a DeepSeek ``provider_tools`` in TOML was dropped on the way — and the preset
defaulted to none, leaving search unavailable for every convention.

DeepSeek now defaults to ``[{type="web_search"}]`` like OpenAI does (``[]``
turns it off) and its list reaches the agent: the Anthropic convention runs the
hosted search inside the response, the Responses and chat conventions get the
local ``web_search`` tool (see ``config.routing.local_web_search``).
2026-10-06 23:59:01 +08:00
NCBM 27a901083a docs: describe the local search tool
The tools list gains the ``web_search`` entry (what it returns, when the CLI
registers it), and the provider-tools bullet says DeepSeek's preset asks for no
hosted tool by default.
2026-10-06 23:45:41 +08:00
NCBM a0d39bba6d core/tools: search through a local tool where a hosted one is ignored
Only DeepSeek's Anthropic-compatible surface runs a hosted ``web_search``: the
Responses and chat-completions surfaces answer "I can't browse the web" with
``{"type": "web_search"}`` and with the server-tool shape alike, so a DeepSeek
provider that talks them had no search at all.

``config.routing.local_web_search`` decides per provider: a DeepSeek provider
whose ``provider_tools`` ask for ``web_search`` and whose convention is not
``anthropic`` gets the local tool (``tools/net/search.py``), which asks the
search-capable endpoint behind the scenes and hands the model one ``title —
url`` line per hit — read a page with ``fetch``, the bodies stay server-side.
DeepSeek's preset asks for no hosted tool by default, so this stays opt-in.
2026-10-06 23:45:35 +08:00
NCBM e2ee937d8f lmproto: search through DeepSeek's Anthropic-compatible endpoint
DeepSeek runs ``web_search`` inside a turn — the request declares the server
tool, the response carries a ``server_tool_use`` per query and the
``web_search_tool_result`` it produced — so a search is one ``/messages`` call
whose hits are read out of the content blocks. The turn stops as soon as it has
searched (a live probe with 64 output tokens spent all of them before the search
and returned nothing, 512 answers with the hits).

Hits carry a title, a URL and an ``encrypted_content`` only the index can read,
so the hit list is all a caller gets: the page bodies stay server-side.
2026-10-06 23:45:29 +08:00
NCBM af232283a4 docs: describe a hosted search on the Anthropic surface
The ``messages_bridge`` bullet says a server tool is the surface's own business
(only ``tool_use`` becomes a local call), the provider-tools bullet says the
Messages surface declares its own definition, the ``anthropic/model.py`` bullet
lists the blocks that now decode, and the file map says as much.
2026-10-06 23:36:41 +08:00
NCBM 21f59ea33c core/agent: count the prompt a search turn really sent
The compat opens with the question alone (``message_start`` reports 165 tokens)
and puts what the search then pulled in on ``message_delta`` (12468); merging
preferred the earlier value, so the CLI's context gauge and the auto-compact
threshold ran ~12k short of the request. The larger of the two wins now (output
tokens are the delta's cumulative count either way).
2026-10-06 23:36:35 +08:00
NCBM 45246bf949 lmproto: keep the thinking block's type on the wire
``omit_defaults`` dropped the block's defaulted discriminator, and a request
that carried thinking was answered with 422 "thinking: missing field ``type``" —
which is what the compat sent for any configured ``reasoning_effort``. The field
has no default now, so it always encodes (both call sites pass it).
2026-10-06 23:36:29 +08:00
NCBM 1fa41c9fde core/agent: run a hosted search as the surface's own tool
DeepSeek's Anthropic compat runs a hosted ``web_search`` itself and answers with
the blocks that search produced — ``thinking``, ``server_tool_use`` (the query)
and ``web_search_tool_result`` — so the content union that knew only text and
tool_use could not decode a live search turn. It carries them now, ``tools``
accepts the opaque server-tool dicts the surface declares, and
``web_search_tool_result.content`` stays loose on purpose: its
``web_search_result`` entries hold an ``encrypted_content`` the API demands back
unchanged (a trimmed result is answered with 422 "missing field
``encrypted_content``").

``provider_tools`` reached only the Responses surface, and the Anthropic one
wants its own definition of the same intent: a bare ``web_search`` becomes
``{type="web_search_20250305", name="web_search"}`` (the server tool Claude
declares and DeepSeek's compat reads, keeping a ``max_uses`` cap or any other
key), merged after the local function tools.

Such a tool is the server's: it streams ``input_json_delta`` fragments like a
call, and reading them as one asked the registry for a tool the server had
already run. Block types are now tracked, so only a ``tool_use`` block becomes a
tool call; the search blocks and ``thinking`` are skipped by the response
mapping (the answer the model wrote already used them) and never executed.
2026-10-06 23:35:46 +08:00
NCBM 88b93a8dfe docs: describe how a DeepSeek round goes back
The DeepSeek bullet says the chat client stamps every assistant round with the
`reasoning_content` thinking mode demands, and the `responses_bridge` bullet
says a round's calls stay contiguous ahead of their outputs and that a round
which kept no chain-of-thought sends a blank line.
2026-10-06 23:23:30 +08:00
NCBM 0da6e8da8d lmproto: give every DeepSeek assistant round its reasoning field
Thinking mode checks the field per round, not per call: an answer whose message
carried no `reasoning_content` came back 400 with the wording a call gets ("the
`reasoning_content` … must be passed back to the API"), while an empty string
satisfies the check. The injection now touches every assistant message instead
of only the ones carrying `tool_calls`.
2026-10-06 23:23:24 +08:00
NCBM c06fb57e72 core/agent: return each round's reasoning in the Responses input
A request carrying `tools` must return every round's chain-of-thought — "必须完整
回传 `reasoning_content` … 即使该轮模型未实际进行工具调用" — and the API merges a
`reasoning` item into the assistant message its round forms: a missing item and
an empty `reasoning_text` are both answered with "The `reasoning_text` in the
thinking mode must be passed back to the API". Each round now goes back as its
reasoning, its text, then its calls, with a blank line standing in when the
round kept none (a `rollback_tail` carrier, a forged todo call, the seed a
compacted session starts with) — never words of the bridge's own for a round
that may genuinely have reasoned nothing. A round's calls stay contiguous ahead
of their outputs: an interleaved batch reads as a second round without
reasoning and is rejected the same way, and an output recorded after another
item moves up beside its own call.

The reasoning is read back from the item's `content` parts, the only part the
API documents for `reasoning` items (`summary` and `encrypted_content` are not
supported).
2026-10-06 23:23:18 +08:00
NCBM 932b4f0a7f core/agent: keep a rolled-back turn's reasoning with its calls
A failed turn keeps the calls that already ran as a bare pairing carrier, and
clipping the chain-of-thought off that carrier sent the call back to the API
with no reasoning at all — thinking mode refuses a call whose reasoning never
came back. The carrier keeps the reasoning and drops only the text.
2026-10-06 23:23:12 +08:00
NCBM 19e247d739 docs: describe the wrapper command-line review
`env -S "…"` / `su -c "…"` hand a command line to a wrapper instead of running
a program, so the soft confirm now covers them alongside interpreters and
detached runners, with the text under its own `env -S:` label.
2026-10-06 21:54:28 +08:00
NCBM 135c28a622 core/tools: review a wrapper option that carries a command line
`env -S "rm -rf /"` ran a command nothing looked at: the option belongs to the
wrapper, so the scan skipped it and the denylist found no program to block while
the soft confirm had no interpreter to review. Collect those values — `env
-S`/`--split-string` and `su -c`/`--command`/`--session-command`, jammed
`--opt=value` and `-Svalue` forms included — and confirm the call the way an
interpreter's `-c` one-liner is confirmed, printing the text under its own
`env -S` label. Recursing into the text for a hard denylist is left out for now:
the review asks the human, who sees the command.
2026-10-06 21:54:18 +08:00
NCBM 2b3b2073a0 core/tools: only value-taking options may swallow the program
The wrapper option table listed flags as if they consumed the next token, so
the program behind them was skipped: `exec -c git -c init.defaultBranch=main
init` resolved to `init` and the policy denied it as a denylisted basename,
while `exec -c rm -rf /`, `command -p rm -rf /` and `command -v rm` hid `rm`
from the denylist completely and `exec -c python -c …` lost the interpreter
review. Drop the entries that take no value — `exec -c`/`-l`, the whole
`command` entry, `pkexec --disable-internal-agent`, `su -p`, and `xargs -e`
(its argument is optional, and the jammed form needs no entry) — so the
resolved program is the one that really runs.
2026-10-06 21:50:21 +08:00
NCBM e05dc5127a docs: describe how a batch of tool calls keeps its rows
The note said a second call ends the open prefix as its own line; that row was
what made the first call of a batch print twice. Record the queued behaviour:
each call of a round prints one finished row, and only output that genuinely
takes the line still closes the prefix.
2026-10-06 21:38:13 +08:00
NCBM 768b310a0b cli/display: hold a batch's later prefixes until the open row is done
A round that asked for several calls printed the first one as an abandoned
half-line: the next prefix closed the still-open row (a bare newline), the
result then printed whole and repeated that prefix, and the rest of the batch
was suppressed. The model edits files in batches, so the duplicate row showed
up there most. Queue the later prefixes instead: each one opens its own row
once the row before it has taken its detail, which keeps one finished row per
call in call order and leaves the bytes identical to the non-interactive path.
Only output that genuinely takes the line — a prompt box, streamed text, an
error — still ends the open prefix as its own line. The cursor is still never
moved.
2026-10-06 21:38:03 +08:00
NCBM 66b771f87d docs: use the current DeepSeek flash id in the samples
The default catalog and the sample config still named `deepseek-v4-flash`,
which DeepSeek keeps only as a compatibility alias. Spell out the mapping
Claude-* ids get server-side (`deepseek-v4-pro` / `deepseek-flash`) instead
of the `deepseek-v4-*` glob the flash rename made stale.
2026-10-06 21:22:55 +08:00
NCBM ec5340bdce config: rename the DeepSeek flash model to deepseek-flash
DeepSeek dropped the `v4` prefix from its flash model, and the old id stays
valid only as a compatibility alias. Seed the default catalog with the current
name so a provider that omits `models` no longer offers a deprecated id, and
follow the rename in the wire fixtures and the minimal config template. An
explicit `deepseek-v4-flash` in TOML still passes through unchanged.
2026-10-06 21:22:35 +08:00
NCBM 35dbd30328 docs: describe the thinking-strength settings 2026-10-06 21:11:28 +08:00
NCBM 60bc5957a7 cli: resolve thinking settings for the chat session 2026-10-06 21:11:28 +08:00
NCBM fb8ef88d9f core/agent: send the resolved reasoning effort with each request 2026-10-06 21:11:28 +08:00
NCBM 445aba7e5f lmproto: carry thinking options on the wire 2026-10-06 21:11:28 +08:00
NCBM ffd4dbbe26 config: resolve thinking strength across agent, provider, and model layers 2026-10-06 21:11:28 +08:00
NCBM 0c9bb2e067 docs: describe merged todo batches 2026-10-06 20:51:13 +08:00
NCBM 7cd5fcc576 cli/display: merge a batch of todo calls into one line
A round that updated its todo list printed one header plus a full stack
render per call, so the same list appeared several times. When a batch only
touches todo tools, print one `* Todo x<N>: Push, Update` line and the last
result, whose text already ends with the final stack. A batch holding any
other tool keeps the per-call lines, and a todo call never prints a prefix
at call time (it would dangle when the batch is merged).
2026-10-06 20:51:07 +08:00
NCBM 49ddf1c3c1 cli: correct the auto-retry schedule in the REPL help
The help said 10s/20s/30s; the delays are 5s/10s/15s/20s, then +10s each step.
2026-10-06 20:33:36 +08:00
NCBM 7d9b979604 core/agent: record every tool result before yielding them
A caller that goes away mid-batch (cancel, display error) left the results of
tools that had already run out of history, so the retry ran them again. Append
the collected results first, then emit the events.
2026-10-06 20:33:30 +08:00
NCBM 4b1cb84256 docs: describe what a rolled-back turn keeps
A failed or cancelled turn drops unfinished assistant text, and tool calls
whose result already arrived stay in history (and in the store), so /retry
continues from real results instead of running those tools again.
2026-10-06 20:32:35 +08:00
NCBM cecd5e5ae3 core/agent: keep executed tool results when a failed turn is rolled back
A tool batch counted as committed only once every call had its result, so a
batch cut short was dropped whole and the retry ran tools that had already
had effects. Keep the calls that did report a result: their assistant row
stays as the pairing carrier (a request must answer every tool_calls id),
without the text of the unfinished turn, and the calls without a result are
absent so the retry re-issues them instead of assuming they ran. The kept
rows go to the store right away, and a failed rollback write is not allowed
to mask the failure being rolled back.
2026-10-06 20:32:09 +08:00
NCBM 1cead73320 docs: describe argument-aware command denylist entries 2026-10-06 19:55:40 +08:00
NCBM c7680491c9 core/tools: denylist power-management subcommands and privileged programs 2026-10-06 19:55:14 +08:00
NCBM f285848dd5 docs: describe wrapper-aware command review 2026-10-06 19:44:50 +08:00
NCBM 7862a555bc core/tools: review interpreters and detached runners past wrappers 2026-10-06 19:44:29 +08:00
NCBM 9501efb3d7 core/tools: block a denylisted program behind env wrappers and run launchers 2026-10-06 19:42:06 +08:00
NCBM 6453a8ac89 core/tools: resolve the program behind env wrappers and run launchers 2026-10-06 19:41:17 +08:00
NCBM 1a63327040 docs: describe ending an interrupted pretty line 2026-10-06 19:15:49 +08:00
NCBM 25b8ef3e58 core/tools: resume fetch bodies from memory instead of refetching 2026-10-06 19:14:38 +08:00
NCBM be16839d9a core/tools: use run_argv names in truncation docs 2026-10-06 19:09:07 +08:00
NCBM 1068ea0025 prompting: end an open tool line before writing a prompt 2026-10-06 19:06:22 +08:00
NCBM a81b138bf8 cli/display: close an interrupted pretty line instead of erasing it 2026-10-06 19:01:42 +08:00
71 changed files with 4497 additions and 603 deletions
+21 -12
View File
@@ -50,17 +50,19 @@ lmproto/deepseek/anthropic/ ← DeepSeek Anthropic client (extends Anthropic
- **`openai_compatible/config.py`** — `OpenAIConfig` (token + base URL).
- **`openai/model.py`** — OpenAI Responses API (`ResponsesCreateParam`, `Response`, function_call items, stream events).
- **`openai/client.py`** — platform `OpenAIClient` (`kind="responses"`): chat completions + `responses` / `get_response` / `delete_response`.
- **`anthropic/model.py`** — Anthropic Messages request/response + SSE event models.
- **`anthropic/model.py`** — Anthropic Messages request/response + SSE event models; the response content union also carries the server-tool blocks DeepSeek's compat returns (`thinking`, `server_tool_use`, `web_search_tool_result` with its verbatim `encrypted_content`), and `tools` accepts opaque server-tool dicts.
- **`anthropic/client.py`** — `AnthropicClient` (`kind="messages"`): `POST /messages` + `GET /models`.
- **DeepSeek** — `DeepseekOpenAIClient` (`kind="chat_completions"`); models add `reasoning_content`, `prefix`, `ThinkingOptions`. Config default model ids: `deepseek-v4-flash`, `deepseek-v4-pro`. `DeepseekResponsesClient` (`kind="responses"`) extends the OpenAI Responses client for `convention = "responses"` (SSE stops at terminal events — DeepSeek sends no `[DONE]`). `DeepseekAnthropicClient` (`kind="messages"`) extends `AnthropicClient` for `convention = "anthropic"` (base `https://api.deepseek.com/anthropic`; `models()` returns `[]`).
- **DeepSeek** — `DeepseekOpenAIClient` (`kind="chat_completions"`); models add `reasoning_content`, `prefix`, `ThinkingOptions`, and it injects the `thinking` flag (`enabled`, or `disabled` for `reasoning_effort = "none"`) after encoding, plus an empty `reasoning_content` on any assistant message that carries none (thinking mode refuses any round — a call or a plain answer — whose reasoning never came back). Config default model ids: `deepseek-flash`, `deepseek-v4-pro`. `DeepseekResponsesClient` (`kind="responses"`) extends the OpenAI Responses client for `convention = "responses"` (SSE stops at terminal events — DeepSeek sends no `[DONE]`). `DeepseekAnthropicClient` (`kind="messages"`) extends `AnthropicClient` for `convention = "anthropic"` (base `https://api.deepseek.com/anthropic`; `models()` returns `[]`).
### Config (`config/`)
TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]` section. Default path via platformdirs. Providers may set `timeout` as a float or `{ connect, read }` (`HttpTimeoutConfig`); omitted → product defaults (10s connect / 600s read). `DeepseekProvider.convention` (Literal: `""`/`"openai"`/`"anthropic"`/`"responses"`) selects the DeepSeek API surface — `"responses"` → OpenAI Responses, `"anthropic"` → Anthropic Messages on `https://api.deepseek.com/anthropic` (default url follows the convention); a per-model `ModelConfig.convention` overrides it (empty = inherit). `[mcp]` (`McpConfig`): `servers.<name>` stdio server definitions (`McpServerConfig`: `command`/`args`/`env`/`cwd`/`timeout`/`read_only`, `url` reserved for streamable HTTP) + `disable` gate; defining a server opts it in. `[skills]` (`SkillsConfig`): `paths` (explicit roots first), `discover` (`plyngent` user dir + `claude` dirs) with `include_project_roots`, `inject_catalog` / `max_catalog_skills`, `enabled`, and `allow_write` (standing write grant); a malformed section degrades to defaults like `[agent]` / `[mcp]`.
`reasoning.py`: thinking strength as `reasoning_effort` (`ReasoningEffortConfig`: `""` = send nothing | `none` … `high` … `max`) and `thinking_budget_tokens`, settable on `[agent]`, a provider, or a model entry — `resolve_reasoning(agent, provider, model)` merges the layers per field (model > provider > agent) and clamps `max` (DeepSeek chat only) to `xhigh` for every other surface, returning a `ReasoningConfig` the agent loop and `create_client` consume.
### Runtime (`runtime/`)
`create_client(provider)` maps config `Provider` → protocol client by effective preset (model-level `preset`/`url` overrides apply). `normalize_http_timeout` feeds OpenAI-compatible configs and Anthropic. Mapping: `openai` → `OpenAIClient` (agent uses Responses), `openai-compatible` / `deepseek` → chat-completions clients (deepseek with effective `convention = "responses"` → `DeepseekResponsesClient`, `"anthropic"` → `DeepseekAnthropicClient`), `anthropic` → `AnthropicClient`.
`create_client(provider, *, model=None, reasoning=None)` maps config `Provider` → protocol client by effective preset (model-level `preset`/`url` overrides apply); `reasoning` (resolved `ReasoningConfig`) carries the Anthropic `thinking_budget_tokens` onto `AnthropicConfig` — the other surfaces read the effort off the request param — and is derived from the provider/model layers alone when omitted. `normalize_http_timeout` feeds OpenAI-compatible configs and Anthropic. Mapping: `openai` → `OpenAIClient` (agent uses Responses), `openai-compatible` / `deepseek` → chat-completions clients (deepseek with effective `convention = "responses"` → `DeepseekResponsesClient`, `"anthropic"` → `DeepseekAnthropicClient`), `anthropic` → `AnthropicClient`.
MCP (`runtime/mcp_client.py`): `McpManager` owns one `McpServerConnection` per `[mcp]` server — a spawned stdio subprocess speaking newline-delimited JSON-RPC 2.0: initialize handshake, `tools/list` (cursor pagination), `tools/call`, `notifications/tools/list_changed` → `tools_stale`. Spawn/handshake failures record `connection.error` (status string), never raise; `ensure_started` / `restart(config)` / `aclose` / `refresh_tools`; `statuses()` → `(server, status, tool_count)` rows; `instructions()` → `(server, text)` for every connected server that returned optional `InitializeResult.instructions` (usage guidance captured per connection); per-request timeout from config (default 30s).
@@ -75,32 +77,33 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
### Agent (`agent/`)
- **Kind-based dispatch**: clients expose `kind` (`chat_completions` | `responses` | `messages`). Agent history stays chat-completions-shaped; transport adapters return synthetic `ChatCompletionResponse` / stream chunks.
- **`responses_bridge` / `responses_dispatch`**: chat ↔ OpenAI Responses; stream text/reasoning deltas; tool calls + usage on `response.completed`.
- **`messages_bridge` / `messages_dispatch`**: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments.
- **Provider-side tools**: `OpenAIProvider.provider_tools` (list of dicts; default `[{type="web_search"}]` when omitted; `[]` disables) threaded via `ChatAgent`/`run_chat_loop` into Responses only; never executed by `ToolRegistry`.
- **`responses_bridge` / `responses_dispatch`**: chat ↔ OpenAI Responses; stream text/reasoning deltas (`response.reasoning_text.done` repeats what its fragments spelled out, so the loop streams each part once and still keeps the whole text in the round's message); tool calls + usage on `response.completed`; a request carrying `tools` returns every round's reasoning as a `reasoning` item of `reasoning_text` parts — "必须完整回传 `reasoning_content` … 即使该轮模型未实际进行工具调用" (a missing item and an empty part are both a 400, so a round that kept none sends a blank line) — and a round's `function_call` items stay contiguous ahead of their outputs (`call, output, call, output` was a 400: the second call reads as a round of its own without reasoning); reasoning is read back from the item's `content` parts.
- **`messages_bridge` / `messages_dispatch`**: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments; `thinking` from the request's `reasoning_effort` (level → token budget, floor 1024, `max_tokens` grown when the budget would leave no room) plus the client's configured `thinking_budget_tokens`; a server tool (`server_tool_use` + its `web_search_tool_result`) is the surface's own business — it streams `input_json_delta` fragments like a call but nothing local asked for it, so only a `tool_use` block becomes a tool call, and the blocks are read (see `lmproto/anthropic/model.py`) but never executed.
- **Provider-side tools**: `provider_tools` on the OpenAI **and** DeepSeek presets (list of dicts; `[{type="web_search"}]` when omitted; `[]` disables) threaded via `ChatAgent`/`run_chat_loop` into Responses and Messages; never executed by `ToolRegistry`. The Messages surface declares its own server tool instead — a bare `web_search` becomes `{type="web_search_20250305", name="web_search"}` (the search then runs inside the response, and its `encrypted_content` results must be passed back verbatim if they are kept at all) — and a DeepSeek provider on a surface that ignores hosted tools (`config.routing.local_web_search`) gets the local `web_search` tool from `tools/net/search.py` instead.
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name. Before running, `execute` answers a call that omits a required argument with the accepted names (`_missing_required_error`) — the handler's bare `TypeError` never reaches the model, and no confirm prompt fires for a call that cannot run. Shape mistakes inside a *present* argument are the tool's own job (see the `chat` bullet).
- **`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`.
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`; resolved `reasoning_effort` on every request (see Config).
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `provider_tools`; `retry()` continues incomplete turns (user-only **or** after committed tools — does not re-run those tools).
- **`/compact`**: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first.
- Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
- 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), `[skills] inject_catalog` (fold the skill catalog — name/source/description only — the same way), `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), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `[skills] inject_catalog` (fold the skill catalog — name/source/description only — the same way), `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.
### Tools (`tools/`)
Module-level `@tool` handlers. Call `set_workspace_root()` before use.
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots **or** a directory-access grant (mode-aware: `read` < `write` < `exec`, default required `write`; a file grant matches only that file); path substring denylist (wins over grants); command basename denylist (CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); escape error hints `request_directory_access`.
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots **or** a directory-access grant (mode-aware: `read` < `write` < `exec`, default required `write`; a file grant matches only that file); path substring denylist (wins over grants); command denylist via `match_command_denylist` (checks every program of the wrapper/`run` chain, resolved by `tools/command_scan.py`; an entry is a program, plus optional argument words that make it dangerous — `systemctl reboot poweroff …` matches `systemctl reboot` and `systemctl start reboot.target`, not `systemctl status`; basenames are lower-cased, so `RM` / `rm.exe` count; defaults cover privilege `sudo`/`su`/`doas`/`pkexec`/`run0`/`systemd-run`, destructive `rm`/`mkfs`/`dd`/`chown`/`mount`/…, and power state `shutdown`/`reboot`/`poweroff`/`halt`/`init`/`telinit`/`kexec`/`pm-*`/`s2*`; CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); escape error hints `request_directory_access`.
- **`access`** (`tools/access.py`): `request_directory_access` (LOCAL|INSTANCE|SESSION) asks the host hook for out-of-workspace access; grants are one `path → mode` pair in one of three stores — `WorkspacePolicy.config_allow` (static `[agent].allow_paths`), `SessionState.access_grants` (human-approved, persisted on the session row), `WorkspacePolicy.yolo_allow` (`--yes`/`/yolo`, process only). `AccessDecision(mode, persist)` from the hook; denylisted paths can never be granted; cap `MAX_ACCESS_GRANTS`. Not a sandbox: `exec` grants (and any command) reach the whole FS.
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range), `write_file`, `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `regex_files` (Python `re` per line, not a glob; literal file/dir `path`, no glob expansion; skips VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path`, `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind), `get_truncated` (resume any truncated result via its `truncate_token=...`).
- **`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 shells/REPLs and `python|bash -c` one-liners (argv + code preview); 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).
- **`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 id; 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.
- **`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 mtime-cached 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.
@@ -118,8 +121,8 @@ Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
- `/btw` side questions: forked session; main transcript/DB untouched. `--tools=read` (default) exposes only `READ_ONLY` tools under `read_only_context()` (fetch=GET) with the main session view; `--tools=no` off; `--tools=full` whole-registry clone with fresh session. Tools off: default falls back to no tools; explicit `read`/`full` errors. The model gets an `aside` notice as its last message before the question (unsaved exchange + tool scope), shown to the user as `[notice] aside: …`.
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**. A session resumed by a **freshly started process** (`--session` / default latest) also gets a `resume` notice about what a restart drops (PTY sessions, running commands, temp workspaces) — resuming inside a live process pushes nothing.
- Host notices (`agent/notices.py`): system-level messages appended as trailing `developer` rows, never system-prompt edits — `Notice.to_message()` for the model, `.summary` for the user via `cli.display.echo_notice` (`--quiet` hides the user line only). Durable hosts persist them (`ChatAgent.push_notice`); a side agent without memory keeps its notice local. Trailing developer messages never end a turn, so `/retry` still sees an interrupted turn behind a notice or checkpoint.
- Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels the in-flight turn (a session-scoped asyncio SIGINT handler routes it, `cli/interrupt.py` — a stray Ctrl+C while the loop waits never tears the REPL down); TTY confirms/ask prompts run off-loop and register their own SIGINT target, so Ctrl+C there cancels just the poll-based read (`wait` reports `cancelled by user`) or is ignored with a hint for readline prompts (`ask_user_*`, confirms) — never the turn, never the loop; auto-retry waits 5s/10s/15s/20s then +10s (10 attempts), its countdown is Ctrl+C-cancellable, and the budget **resets when an attempt completes a model round** (connection recovered); then `/retry`.
- Pretty tool lines: every builtin tool prints its `* Verb 'target' ` prefix as the call starts and appends the outcome (`L1-4 (done)`, `(exit code 0)`, `(session 3)`) when the result lands, so a blocking tool stays visible while it runs; namespaced MCP tools share one generic line (`* MCP <server>:<tool> (done)`). Plugin tools and custom tool calls keep the `[tool] …` / `[tool ok] …` whole-line style. Interactive TTY only (`interactive_terminal()`): piped/plain output and `--verbose` keep whole lines. A second call before the first result (parallel batch) erases the open prefix — only that line, never the streamed line above it — and falls back to whole lines.
- Failed/cancelled turns: user kept; **committed tool rounds kept**, and a batch cut short keeps the calls that already produced a result (`rollback_tail`; written to the store, so a resume continues from them instead of re-running them) — side effects are not re-run on `/retry`, and only unfinished assistant text is dropped; Ctrl+C cancels the in-flight turn (a session-scoped asyncio SIGINT handler routes it, `cli/interrupt.py` — a stray Ctrl+C while the loop waits never tears the REPL down); TTY confirms/ask prompts run off-loop and register their own SIGINT target, so Ctrl+C there cancels just the poll-based read (`wait` reports `cancelled by user`) or is ignored with a hint for readline prompts (`ask_user_*`, confirms) — never the turn, never the loop; auto-retry waits 5s/10s/15s/20s then +10s (10 attempts), its countdown is Ctrl+C-cancellable, and the budget **resets when an attempt completes a model round** (connection recovered); then `/retry`.
- Pretty tool lines: every builtin tool prints its `* Verb 'target' ` prefix as the call starts and appends the outcome (`L1-4 (done)`, `(exit code 0)`, `(session 3)`) when the result lands, so a blocking tool stays visible while it runs; namespaced MCP tools share one generic line (`* MCP <server>:<tool> (done)`). Plugin tools and custom tool calls keep the `[tool] …` / `[tool ok] …` whole-line style. Interactive TTY only (`interactive_terminal()`): piped/plain output and `--verbose` keep whole lines. A second call of the same round holds its prefix back until the open row takes its own detail (results arrive in call order), so each call still prints one finished row; a prompt box (or other output) printed while the tool runs *ends* the open prefix as its own line and falls back to whole lines for the rest of the batch — the cursor is never moved, so a prefix wider than the terminal cannot be half-erased. A batch (one round's calls) that only touches todo tools prints one `* Todo x<N>: Push, Update` line plus its last result, whose text already carries the final stack, instead of one header and a full list render per call.
- **`plyngent providers`**, **`config path|edit`**. Config open: `$VISUAL`/`$EDITOR` (wait), else system default (`xdg-open` / `open` / `startfile`, non-blocking). `/edit` stays blocking-only. No providers → optional edit then reload when waited.
- Tools default on; workspace defaults to cwd; `--max-rounds` default 32. Readline history under platformdirs (`repl_history`). PTY: `close_all` on chat exit.
@@ -131,6 +134,12 @@ Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
Basedpyright `recommended`. Ruff includes `ANN` (private return types `ANN202` ignored). Prefer PEP 695 aliases except where msgspec requires plain assignment (`Unset`).
### Function complexity (`C901`)
Ruff's `C901` stays at its default **10** (no `mccabe` section in `pyproject.toml`): one decision point per `if` / `elif` / `for` / `while` / `except` handler, one per `match` case past the first, plus one per nested `def`. Boolean operators, ternaries, comprehensions, `with`, and `assert` are free — so a wide flat guard is fine and a case-per-branch dispatcher is not. That is the intent: the few functions over it here keep a `# noqa: C901` with the reason after the codes (`— one branch per event type`), because the count is what those functions are. `PLR0912` is ignored (same branches, looser default, and `C901` already flags every function it would); `PLR0911` / `PLR0915` stay — returns and statements are different axes.
A nested function is counted **twice**: its decision points are added to the enclosing function *and* reported on the nested function itself (`def outer` with one helper `if` and one own `if` scores 3; the helper scores 2 on its own). A function that only scores high because of its closures is asking for those to be hoisted to module level, not for a wider limit.
## Roadmap notes (single-user → platform)
- **Phase D (context quality)**: soft char budget, request compact, `/compact`, richer errors/cancel, workspace sessions. Context size is **char estimate** plus optional API usage when reported.
+25 -6
View File
@@ -154,6 +154,11 @@ max_context_tokens = 200000
# allow_paths = { "/data/datasets" = "read", "/tmp/build" = "exec" }
# Auto-raise tool/PTY limits without prompting (same as answering yyy).
# auto_continue_limits = false
# Thinking strength: "" (provider default) | none | minimal | low | medium |
# high | xhigh | max. See "Thinking strength" below; providers and single
# models may override either value.
# reasoning_effort = "medium"
# thinking_budget_tokens = 8000 # Anthropic only; overrides the level's budget
# Optional skills (instruction directories). See doc/skills.md.
# [skills]
@@ -172,6 +177,20 @@ max_context_tokens = 200000
Per-provider **`timeout`** is passed to the HTTP session for chat/completions, Responses, and `GET /models`. A single number sets one timeout; `{ connect, read }` splits TCP/TLS setup vs idle wait between response bytes (SSE can run longer than `read` while chunks keep arriving). Tool/process timeouts (`run_argv`, PTY, policy confirm) are separate.
**Thinking strength** (`reasoning_effort`) can be set on `[agent]`, on a provider, or on a single model: the most specific layer wins, per field, so a model may override only the effort or only the budget. `""` sends nothing and lets the provider apply its own default. Each API surface maps the value onto its own wire field — chat completions send `reasoning_effort`, Responses send `reasoning.effort`, and Anthropic has no effort level at all, so it sends `thinking` with the token budget that level maps onto (`minimal` 1024 … `xhigh`/`max` 32768; `none` disables thinking). `max` is DeepSeek's own top level on chat completions and clamps to `xhigh` everywhere else. `thinking_budget_tokens` overrides that mapping; either way the request stays valid: the budget never drops below 1024, and when it would swallow the output limit `max_tokens` grows so the answer still has room. DeepSeek chat keeps `thinking.type = "enabled"` unless the effort is `none`.
```toml
[providers.deepseek]
reasoning_effort = "high" # provider default
[providers.deepseek.models]
"deepseek-v4-pro" = { text = true, reasoning_effort = "max" } # per-model wins
[providers.anthropic]
reasoning_effort = "medium"
thinking_budget_tokens = 16000 # explicit Anthropic thinking budget
```
Third-party **plugins**: install a package that declares `project.entry-points."plyngent.tools"` (and later other groups), then allowlist the entry-point name under **`[plugins].enable`**. Details: [doc/plugins.md](doc/plugins.md).
**Skills** (instruction directories with a `SKILL.md`): discovered under `[skills].paths`, our own user skills directory, and other harnesses' skill directories (`~/.claude/skills`, plus `<workspace>/.claude/skills` when `include_project_roots`). The catalog — name, source, description — is folded into the system prompt when tools are on; the model reads a `SKILL.md` with `skill_read`, searches with `skill_search`, and creates or updates skills with `skill_create` / `skill_edit` behind a timed `once`/`session`/`no` grant (`[skills].allow_write = true` for a standing one; the path denylist still wins and `--yes`/`/yolo` never skips it). Reading needs no grant: skill roots are read-only roots, so `read_file` / `regex_files` see them too while writes stay denied. Details: [doc/skills.md](doc/skills.md).
@@ -184,10 +203,10 @@ Supported provider presets today:
|--------|------------------------|--------|
| `openai` (default if `preset` omitted) | OpenAI **Responses** (`POST /responses`) | Default models `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-nano` when `models` is omitted; optional `provider_tools` (default `web_search`) |
| `openai-compatible` | Chat Completions | Generic hosts (vLLM, LiteLLM, proxies); requires `url` |
| `deepseek` | Chat Completions (DeepSeek) | Default models `deepseek-v4-flash` / `deepseek-v4-pro` when `models` is omitted; set `convention = "responses"` (provider or per-model) to use the OpenAI **Responses** API (`POST /responses`, currently only `deepseek-v4-flash`), or `convention = "anthropic"` to use the Anthropic **Messages** API on `https://api.deepseek.com/anthropic` |
| `deepseek` | Chat Completions (DeepSeek) | Default models `deepseek-flash` / `deepseek-v4-pro` when `models` is omitted; set `convention = "responses"` (provider or per-model) to use the OpenAI **Responses** API (`POST /responses`, currently only `deepseek-flash`), or `convention = "anthropic"` to use the Anthropic **Messages** API on `https://api.deepseek.com/anthropic` |
| `anthropic` | Anthropic **Messages** (`POST /messages`) | Native tools/streaming; set `access_key_or_token` (API key) |
Model-level `preset` / `url` overrides on a catalog entry can route a single provider name to a different API (e.g. gateway + Anthropic model). DeepSeek models may also carry a per-model `convention` (empty = inherit the provider-level one), e.g. serve `deepseek-v4-flash` over Responses while `deepseek-v4-pro` stays Chat Completions:
Model-level `preset` / `url` overrides on a catalog entry can route a single provider name to a different API (e.g. gateway + Anthropic model). DeepSeek models may also carry a per-model `convention` (empty = inherit the provider-level one), e.g. serve `deepseek-flash` over Responses while `deepseek-v4-pro` stays Chat Completions:
```toml
[providers.deepseek]
@@ -195,7 +214,7 @@ preset = "deepseek"
access_key_or_token = "sk-..."
[providers.deepseek.models]
"deepseek-v4-flash" = { text = true, convention = "responses" }
"deepseek-flash" = { text = true, convention = "responses" }
"deepseek-v4-pro" = { text = true }
```
@@ -203,8 +222,8 @@ DeepSeek's Anthropic convention (`convention = "anthropic"`) points at
`https://api.deepseek.com/anthropic` by default and is fully supported by the
agent's Anthropic Messages path (tools, streaming, usage). DeepSeek maps
`claude-opus*` model ids to `deepseek-v4-pro` and `claude-haiku*` /
`claude-sonnet*` (and unknown ids) to `deepseek-v4-flash` server-side; real
`deepseek-v4-*` ids pass through as-is. `GET /models` is not documented on that
`claude-sonnet*` (and unknown ids) to `deepseek-flash` server-side; real
DeepSeek ids pass through as-is. `GET /models` is not documented on that
base, so model selection is config-driven.
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).
@@ -289,7 +308,7 @@ Type `/help` in the REPL for the live list. Common ones:
| `/config` | Edit `plyngent.toml` ($VISUAL/$EDITOR or system open); reload after blocking editor |
| `/quit` | Leave the REPL |
User messages are saved immediately. On API error or Ctrl+C, partial assistant/tool output is discarded but the user message stays so `/retry` works after resume. Interactive auto-retry waits 5s, 10s, 15s, 20s, then +10s each step (10 attempts); an attempt that gets a full model round back **resets that budget**, so a connection that recovers and drops again starts over from 5s.
User messages are saved immediately. On API error or Ctrl+C, unfinished assistant text is discarded — tool calls that already produced a result stay, so `/retry` does not run them again — but the user message stays so `/retry` works after resume. Interactive auto-retry waits 5s, 10s, 15s, 20s, then +10s each step (10 attempts); an attempt that gets a full model round back **resets that budget**, so a connection that recovers and drops again starts over from 5s.
Ctrl+C cancels the in-flight turn, and during the auto-retry countdown it cancels the retry. Model-initiated prompts run off the event loop: a `wait` prompt is cancelled by Ctrl+C (the tool reports `cancelled by user` and the turn continues), while `ask_user_*` and confirm prompts ignore it with a one-line hint — answer them to continue. Ctrl+C never exits the REPL (use Ctrl+D or `/quit`), and MCP servers run in their own process group, so it never kills them either.
+31 -6
View File
@@ -96,6 +96,20 @@ max_context_tokens = 200000
# directive_reminder_tokens = 100000
# directive_reminder_text = ""
# Thinking strength, resolved as [agent] < provider < model (the most specific
# layer wins, per field). "" = send nothing and keep the provider's own default.
# none | minimal | low | medium | high | xhigh | max
# ``max`` is DeepSeek's own top level on chat completions and clamps to ``xhigh``
# everywhere else. The agent maps the value onto each API surface: chat
# completions send ``reasoning_effort``, Responses send ``reasoning.effort``, and
# Anthropic — which has no effort level — sends ``thinking`` with the token
# budget the level maps onto (minimal 1024 … xhigh/max 32768, none = disabled).
# reasoning_effort = "medium"
# Explicit Anthropic thinking budget in tokens, overriding that mapping; the
# request stays valid either way (floor 1024, and ``max_tokens`` grows when the
# budget would leave no room for the answer).
# thinking_budget_tokens = 8000
# Optional: custom compact/summarisation prompts (empty = built-in defaults).
# compact_system_prompt = ""
# compact_user_prefix = "Summarize the following conversation:\n\n{transcript}"
@@ -120,6 +134,9 @@ access_key_or_token = "sk-replace-me"
# preset = "openai"
# access_key_or_token = "sk-replace-me"
# url = "https://api.openai.com/v1"
# # Thinking strength for every model of this provider ("" = inherit [agent];
# # a model entry may override it). Values as in the [agent] section above.
# reasoning_effort = "medium"
# # Hosted/provider-side tools (OpenAI runs them; not local ToolRegistry).
# # Default if omitted: [{ type = "web_search" }]. Use [] to disable.
# # Examples: web_search, file_search (+ vector_store_ids), code_interpreter, mcp.
@@ -135,6 +152,8 @@ access_key_or_token = "sk-replace-me"
# # `preset = "openai"` means /responses; `openai-compatible` means /chat/completions;
# # `anthropic` means native /messages.
# # "claude-sonnet" = { text = true, preset = "anthropic", url = "https://api.anthropic.com/v1" }
# # A model entry may also set its own thinking strength, e.g. skip thinking for
# # a cheap model: "gpt-5.4-nano" = { text = true, reasoning_effort = "none" }
# --- Anthropic (Messages API) ---
# [providers.anthropic]
@@ -142,13 +161,17 @@ access_key_or_token = "sk-replace-me"
# access_key_or_token = "sk-ant-replace-me"
# # url defaults to https://api.anthropic.com/v1 when empty
# timeout = { connect = 10, read = 600 }
# # Anthropic has no effort level: an effort becomes a thinking token budget
# # (see the [agent] section above). Set the budget explicitly to pin it:
# reasoning_effort = "high"
# thinking_budget_tokens = 16000
#
# [providers.anthropic.models]
# "claude-sonnet-4-20250514" = { text = true }
# "claude-opus-4-20250514" = { text = true }
# "claude-opus-4-20250514" = { text = true, reasoning_effort = "xhigh" }
# --- DeepSeek (OpenAI-compatible / Responses / Anthropic conventions) ---
# Default models if [providers.*.models] is omitted: deepseek-v4-flash, deepseek-v4-pro.
# Default models if [providers.*.models] is omitted: deepseek-flash, deepseek-v4-pro.
# [providers.deepseek]
# preset = "deepseek"
# access_key_or_token = "sk-replace-me"
@@ -157,14 +180,16 @@ access_key_or_token = "sk-replace-me"
# # Convention selects the API surface:
# # "" / "openai" → OpenAI-compatible chat completions (default)
# # "responses" → OpenAI Responses API (POST /responses; today only
# # deepseek-v4-flash supports it)
# # deepseek-flash supports it)
# # "anthropic" → Anthropic Messages API (POST /messages on
# # https://api.deepseek.com/anthropic; claude-* model ids
# # map server-side to deepseek-v4-*)
# # map server-side to deepseek-v4-pro / deepseek-flash)
# convention = "responses"
# # Legacy location (parsed but ignored; prefer the typed field above):
# # extras = { convention = "openai" }
# # Thinking strength for this provider; per-model entries below may override it.
# # reasoning_effort = "high"
#
# [providers.deepseek.models]
# "deepseek-v4-flash" = { text = true, convention = "responses" }
# "deepseek-v4-pro" = { text = true }
# "deepseek-flash" = { text = true, convention = "responses" }
# "deepseek-v4-pro" = { text = true, reasoning_effort = "max" }
Generated
+244 -195
View File
@@ -5,7 +5,7 @@
groups = ["default", "dev"]
strategy = ["inherit_metadata"]
lock_version = "4.5.1"
content_hash = "sha256:94dc16bf6ed12e2fcd7a109b500e9b2d7e39f224b60472d64ecb1d3436ddbb82"
content_hash = "sha256:b69c90f70f1118ebb9080c3e0190e8b4d85dd4e5999466b617430399d0ab3125"
[[metadata.targets]]
requires_python = "~=3.14"
@@ -38,112 +38,112 @@ files = [
[[package]]
name = "basedpyright"
version = "1.39.9"
requires_python = ">=3.8"
version = "1.40.2"
requires_python = ">=3.10"
summary = "static type checking for Python (but based)"
groups = ["dev"]
dependencies = [
"nodejs-wheel-binaries>=20.13.1",
]
files = [
{file = "basedpyright-1.39.9-py3-none-any.whl", hash = "sha256:6b0837b9eba972c71895167ab9b127e6afdbc17abc92312e3f8d15ca82a5611c"},
{file = "basedpyright-1.39.9.tar.gz", hash = "sha256:32cbea5fc8273e89df3db20daea56cb7286e419ccdfdc479c64759d2dc071901"},
{file = "basedpyright-1.40.2-py3-none-any.whl", hash = "sha256:3a53aeb93a86675ded0e12bc27bd8476c1742356de07ad8cab67bff18110c690"},
{file = "basedpyright-1.40.2.tar.gz", hash = "sha256:d4db36d095230379acc66c7e5fba7e4b50ca902f6e803d696782dd031cb8d7d7"},
]
[[package]]
name = "charset-normalizer"
version = "3.5.1"
version = "3.5.2"
requires_python = ">=3.7"
summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
groups = ["default"]
files = [
{file = "charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8"},
{file = "charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102"},
{file = "charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5"},
{file = "charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3"},
{file = "charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36"},
{file = "charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7"},
{file = "charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b"},
{file = "charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b"},
{file = "charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f"},
{file = "charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7"},
{file = "charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9"},
{file = "charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712"},
{file = "charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a"},
{file = "charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d"},
{file = "charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc"},
{file = "charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959"},
{file = "charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e"},
{file = "charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6"},
{file = "charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3"},
{file = "charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187"},
{file = "charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad"},
{file = "charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf"},
{file = "charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995"},
{file = "charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424"},
{file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13"},
{file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d"},
{file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4"},
{file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438"},
{file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a"},
{file = "charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56"},
{file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd"},
{file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204"},
{file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7"},
{file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd"},
{file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc"},
{file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874"},
{file = "charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655"},
{file = "charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0"},
{file = "charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c"},
{file = "charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253"},
{file = "charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709"},
{file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084"},
{file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb"},
{file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f"},
{file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09"},
{file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80"},
{file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c"},
{file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f"},
{file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03"},
{file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604"},
{file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8"},
{file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93"},
{file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915"},
{file = "charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5"},
{file = "charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc"},
{file = "charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105"},
{file = "charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26"},
{file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364"},
{file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253"},
{file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0"},
{file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc"},
{file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229"},
{file = "charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5"},
{file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98"},
{file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3"},
{file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2"},
{file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf"},
{file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95"},
{file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d"},
{file = "charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847"},
{file = "charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8"},
{file = "charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a"},
{file = "charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1"},
{file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b"},
{file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f"},
{file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3"},
{file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e"},
{file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9"},
{file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a"},
{file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115"},
{file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c"},
{file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d"},
{file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d"},
{file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4"},
{file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b"},
{file = "charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800"},
{file = "charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21"},
{file = "charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58"},
{file = "charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd"},
{file = "charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7"},
{file = "charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f"},
{file = "charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93"},
{file = "charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade"},
{file = "charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0"},
{file = "charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26"},
{file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011"},
{file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621"},
{file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4"},
{file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e"},
{file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c"},
{file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0"},
{file = "charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf"},
{file = "charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036"},
{file = "charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e"},
{file = "charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685"},
{file = "charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef"},
]
[[package]]
@@ -323,33 +323,58 @@ files = [
[[package]]
name = "msgspec"
version = "0.21.1"
version = "0.22.0"
requires_python = ">=3.10"
summary = "A fast serialization and validation library, with builtin support for JSON, MessagePack, YAML, and TOML."
groups = ["default"]
files = [
{file = "msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f"},
{file = "msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a"},
{file = "msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f"},
{file = "msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb"},
{file = "msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df"},
{file = "msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f"},
{file = "msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea"},
{file = "msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93"},
{file = "msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2"},
{file = "msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b"},
{file = "msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847"},
{file = "msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7"},
{file = "msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75"},
{file = "msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca"},
{file = "msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63"},
{file = "msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90"},
{file = "msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c"},
{file = "msgspec-0.22.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:221cbcbfa4478152b91d37dcfd4830e2be92773e8139e883f43773450ebacef8"},
{file = "msgspec-0.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dd9568695911055440d2bb7099ed9098fc181d335daa772d0eb3fe8f31ba4efb"},
{file = "msgspec-0.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f039ef5207b847f075a0a43020ee6140cd47505f890e47e157f2deb485c2dc96"},
{file = "msgspec-0.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4f7e09cceac7dbf4c0761b8ae7df51c55b5df5e9af7aff2c895aac1ebea015"},
{file = "msgspec-0.22.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:614e2c827e0a3f934f3cf0cf4ba65210df8132b75a69a8a1f51bb3b2caf0ac5a"},
{file = "msgspec-0.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3689b9dfcc663358ef23ba4299d7460f01108515b041a7d30d05908ac9c32f"},
{file = "msgspec-0.22.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2f950239ff1fc7322c6f9634807310265149cb168270d3ddcdda5b6ada13a28"},
{file = "msgspec-0.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c789b5ccd07c0a3c09767108ee06e089b2875f2309a4569c2648f30a8d31dfa"},
{file = "msgspec-0.22.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a66b1766311e42371e509c996c3933b161c7ae0eabdf361af5316dec197e1022"},
{file = "msgspec-0.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:749899563d26b211379f142b8ffd7e2d7da149a51717798f0ce994dce50324f0"},
{file = "msgspec-0.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:10d0d1d464960d99a949f7ca01ef8928e51c472433a5f5ab74b2d695fb830652"},
{file = "msgspec-0.22.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e79725246291516a7359caad5fb743ddc0ec66ed40d2381fb846325b5031504e"},
{file = "msgspec-0.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:38f7022fbe91954b31afe3888a0af1b652e0f370fafdeb1d425f4a814d789c9f"},
{file = "msgspec-0.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6d3ca19a8ff28d0a67a1824e2bff7ec649ec795c80a265f20ade4caa63080de"},
{file = "msgspec-0.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8b98ae215a102cbf6635f7df45f5c4af12f77fad1f7b71b9808fcf868a5735d"},
{file = "msgspec-0.22.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e0aa0cc3f18c35bab79bd7b87fde95d6274a9deddeebd1ea541f8066a5073165"},
{file = "msgspec-0.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c8e84789918fbc15a503b92a829115ddd7567ecd3e4778bd418c56abbb86c11"},
{file = "msgspec-0.22.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:3ca7d4cd69fbb66bd2da6211d3e79d40542d196c16c6d99bf838f76767ad35be"},
{file = "msgspec-0.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:28f53f3604dd3e70225f7563c831628dbb03299b428f8e62aadb4b628e386874"},
{file = "msgspec-0.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7293dee54de040cfa225c22151cc3d72f17cd674b5ebcb52f38fb9f5701592e6"},
{file = "msgspec-0.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c3c510aba9015c085e514b75a9b3f1ed7c4591ae5e379655821b8bba51f30cc7"},
{file = "msgspec-0.22.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:263e110955ed76fe0af2d79f819903b50a70dc0e7a752eb7aabe79d2e0a084fb"},
{file = "msgspec-0.22.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c6f06576eced70462179a4b4638e84cf69fdbba37f44d13a64a21739c131a830"},
{file = "msgspec-0.22.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d67582478b0eaabb899f2fb255c878ee7de57dff80eb73ab24f1865524ec441"},
{file = "msgspec-0.22.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:71cbbdb39631064e2f2f9e9ac2b1b69931d72276eb5f9da4ed025726296bdbb6"},
{file = "msgspec-0.22.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0a5c25516e2034b2db7767081759ff8996e214def9c43b3055f61e1be1caad"},
{file = "msgspec-0.22.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a1dab6a99c759d1391ab2993388c1892746a697254f4b5dc6c059ca6e3bfbc8b"},
{file = "msgspec-0.22.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:a52eba5c9528fd181fcec39d22b67aaa1dccc6cfe8e24d3f5d41130e6d04289d"},
{file = "msgspec-0.22.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1e547966017265c0d23342bcf2e027305dde40ea042d16694a9b96b4f696a052"},
{file = "msgspec-0.22.0-cp315-cp315-win_amd64.whl", hash = "sha256:0067057df265795f742658b15dbe53f3b6f21d19dcfa53676db11088cfa41e0a"},
{file = "msgspec-0.22.0-cp315-cp315-win_arm64.whl", hash = "sha256:05dbc8268e50c9232ec72b9af1c7b13049aade4d1197764e38c427048706e046"},
{file = "msgspec-0.22.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:b3113ebcceeb7693a915183c73d92c10bf5c62851dd187cab43bd025fb587419"},
{file = "msgspec-0.22.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dfadea8bdcfafc614bd031de55a8ede22b43445cfff6d8b77cc0c07d3edc8a8"},
{file = "msgspec-0.22.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7a738826936c72348c613061d260446f13c82b6fd7d5d7705b6911ab8dca2f3"},
{file = "msgspec-0.22.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2ddea9d78d09460f06c26a7a508adcd049761c3208776162b8eb79b8a032cff"},
{file = "msgspec-0.22.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884c28c80b0a511595b29a9b04a3a230c3797369e4a033e6d5c6d9b5427f8e09"},
{file = "msgspec-0.22.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f7a923bcde480065c8e25967464cfb2a687ee67000bb43157e2d57e40eca7305"},
{file = "msgspec-0.22.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:65eea14bc65ccfeb8f3af62cb204841871e2961f002d7fa87dbe0f79dacf1c1c"},
{file = "msgspec-0.22.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0666a1520cab86796612e794e71107e0fbf5e8ff3ddcdfcfff8f1d94b860d2f1"},
{file = "msgspec-0.22.0-cp315-cp315t-win_amd64.whl", hash = "sha256:885c6e0c89d6103648525fe62aa78d600054dedf7b3713d23b15d7ddb6d66a13"},
{file = "msgspec-0.22.0-cp315-cp315t-win_arm64.whl", hash = "sha256:268594d0bae5510572599a6ab0364dd9de43c867d24a30856cd9f5edb63d8dc6"},
{file = "msgspec-0.22.0.tar.gz", hash = "sha256:0a13624a4969159fe35d8c2a3d377b2b61bbd8585e327440d5e52725affcce38"},
]
[[package]]
name = "niquests"
version = "3.21.1"
version = "3.21.2"
requires_python = ">=3.7"
summary = "Niquests is a simple, yet elegant, HTTP library. It is a drop-in replacement for Requests, which is under feature freeze."
groups = ["default"]
@@ -359,8 +384,8 @@ dependencies = [
"wassima<3,>=1.0.1; sys_platform != \"emscripten\"",
]
files = [
{file = "niquests-3.21.1-py3-none-any.whl", hash = "sha256:ac1ff8061cb480cdaad07f08b95cf21ee0a68ef21fbc2b6ddf5afff8e0d0072e"},
{file = "niquests-3.21.1.tar.gz", hash = "sha256:8ba6ee8712570ae41dfc59ac2ac7e509dcde660c6cc47078b43be423a2f99e64"},
{file = "niquests-3.21.2-py3-none-any.whl", hash = "sha256:73e52614a712ee2f4d410bdd29789ca6a4ed031f8961410156e395d42b091208"},
{file = "niquests-3.21.2.tar.gz", hash = "sha256:586d6b9475c9018a42f19f1e4688077b8158101cbf40673c63d2c1a096cc7665"},
]
[[package]]
@@ -397,13 +422,13 @@ files = [
[[package]]
name = "platformdirs"
version = "4.11.12"
version = "4.12.3"
requires_python = ">=3.10"
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
groups = ["default"]
files = [
{file = "platformdirs-4.11.12-py3-none-any.whl", hash = "sha256:b1ba966ac76153d9985a0787ffab951e7c18416189873d4e4ddb2c8a04a0a254"},
{file = "platformdirs-4.11.12.tar.gz", hash = "sha256:e8dc1cb58f1153fd7f61db1374317770baababec2480b37b8f01c6cc25b45267"},
{file = "platformdirs-4.12.3-py3-none-any.whl", hash = "sha256:080f3b39423b5abfca9a23d84c4e9795f54d395cd8459867a8ded44084fcd5f8"},
{file = "platformdirs-4.12.3.tar.gz", hash = "sha256:427fc0bb321ae0c5b037fa03238ca74820437be162e78b4848c4d4055b9b766c"},
]
[[package]]
@@ -481,47 +506,47 @@ files = [
[[package]]
name = "qh3"
version = "2.0.3"
version = "2.0.4"
requires_python = ">=3.7"
summary = "A lightweight and fast implementation of QUIC and HTTP/3"
groups = ["default"]
marker = "(platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\""
files = [
{file = "qh3-2.0.3-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:db98ea31b7486cac5dffc76c15b34c08208b219379aa9d274df21b1d47c7dbcb"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bb0a8f54275e0ecb722c0a6cebeff73771b31da0dbfaed8e4b72efcbe98e2ab"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e466a285b8f598ea06a296910a817571f132bb3ff3f5a26cdc905ffe6d9d743c"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a266a68f27a86d6d49978e1c6b8e9e5dd514e018a042dafca48cbeb50a29d0d3"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2646d05d20e7171d928d9b478097743d0d2a5b3893f8caddd7984b2825d90daa"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ccac4672539eb2897c3beaa73b626460040adf94c30edbd420db3988bd08e0f1"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0e57b96af05653ddcc4797fe761918d5d0a25ed90a9176260333f48e7b88f72"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73845b61987f44c5d55f9606a38b4078cac7e816dadeeaceb35a49ff741d4c8e"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:4f05f1b3ac250213db0da6895aeb944ca3cb63cd1a41296f1dddafd8d78a1722"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3be9e2cfc447807c4195c12d9590052ee45cc17ed92d99f44d05a49d657bf8d4"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:bb83310f908a9d00b781fe95f7acda51025d0cea3a985929e84b0d46333709a2"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:991ff127a8b0aef9ae07a4155f595158fd005d9cc522d204ec31a5a5f197517c"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:efbcd87e7c45f51690f6e526ed4956c57e428eb4c5684878987d43ffa14a93b0"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:86f2256aa78604d6733ff12a225d6780df18a9b171e72d45dd30ee62d5ab0568"},
{file = "qh3-2.0.3-cp314-cp314t-win32.whl", hash = "sha256:91569e8ee979c84a1972916cc9b3283760dda111fc8f67cce45ccdb4f14b67c5"},
{file = "qh3-2.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:eb0333f67bee13a3b5f08e91ece87fd0f5e5448e955efb11d7bd2bfacf21bfb5"},
{file = "qh3-2.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:8c9f6b22d073948b3042ec0b50dab5581865a30d41517ba6aad8dbc1a4b39a6b"},
{file = "qh3-2.0.3-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e96296b00eb23ff828d7789070b3d22c7139efbc6bc62fed366c31bad622d174"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a09e33277ded5f94f9b73b0c8c2f5b3e044f3c41ed5c830ab61e72962e22d09"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8bbbc57b1cff5acebf736965c072f35023f4d0e5975b3d8cda7b7e07250af1b"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06b8ee7434cd0ecab6b916abc63a46f4ad8b3ebbc284da405aeb650882826e91"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ec715af40af199c92eedbd620bbff8ddc514e19f2bbd660e65953752c364105f"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bcbdc6a574f83f957e6bf430cda8b98a512870164563b7ef1fdd553420f032a"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5c9ba43b3f68f9ade9553ff23d89455596b9c5ba8179a103f75c8771d355535"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51480d90b4ac7e81248fd020a726d4089cf8b881a4b6cbdc3c04c329831a3fc6"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:235161f4cbd791e18ea865d10fdd52d0ddbcbb807a1849c98360c4e570c06844"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7abb8ae6c8ae6b49827b26eaffeac0892893450d2022818e1db3ff34d26a835e"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:57f43869ef306877167277c48c1fa7c446fc73fc9355fa1ccae5094507b63d9e"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:192021606e283a59b47bf1e1cb719087c77869976aea76f26d589a1df8aa0dd5"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:f33ca1ac278a8d8ff115bcde76fc4555fa1dbfa147e83dad2ebcba6bc663e6d5"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:74814e3dd91011235905856701037ead68f7a8be641f469fa5e5cdabf7d03a28"},
{file = "qh3-2.0.3-cp37-abi3-win32.whl", hash = "sha256:20ceb5482ef6f352d9c7351fbde69f94f49d07f068ce9aa4e88ea76fca7eddda"},
{file = "qh3-2.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:94ba6ddb786742ff93904db09cb4b6fbcaf7de115161c05918024cd52b2c8a61"},
{file = "qh3-2.0.3-cp37-abi3-win_arm64.whl", hash = "sha256:0b13789c68983a994e8c12e3ff0fbc02f07c0aaaeec5670b57802f5d3d555e81"},
{file = "qh3-2.0.3.tar.gz", hash = "sha256:546ab2d3193e37e98dfefe5834484e07d0bf531708f21e1a4e227a51510b648f"},
{file = "qh3-2.0.4-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a64bf88e94862d54a0406db92b4c51e86775e2fa5223618f92431f7ea0e668a1"},
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d9d22cdf5a643f3f3236b3e25462d33083f88d200425afd09958cc0d2c5ca54"},
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6f9d726d83942a2cb20ee127e995a0249dbea3ffe2b3772eb113acfdfd3874a1"},
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:968a94dcdc85219794d44fdce7b15ce6a90ca295f2574881b623ebe06850311c"},
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bcae368dc6b5449397f81208493361a5197df1f955d4883b164294915f83638b"},
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36f201b834d96759a241171a4cdc6ab7442a6c52260d1dcfb1879707b430c6d7"},
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f47b1f2c5fe8d70472a6134f374b6022ad6f709d6be56282ad4024d28905829"},
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:edc81973c3823533d75da5570e89214d87aded6205ac76cb9ae3bb9f25b5ffe6"},
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1964c6471d5e4c074a002bf6a95c1797576165c000d74448260426e10209bed0"},
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:690cf4bcd8c7baf075187d6197ab31cc933d2aa951311dc65729cb01dccc92dc"},
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:8449e99c275b04c866f13f24e6d656d609fe8875fedf5896201a93a60bbe1412"},
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:db25d8132c966ccebc294b8aeb197d2f48d14f772e675ff8282ab958009b801b"},
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:c03566def5374925a6270bbc1dafc8c8889b3ff3ecd14396979ea07fec9def26"},
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:19cd7751da3f4d581ea6f5f44510a2b30a19495daba4e632c4e96d8375856694"},
{file = "qh3-2.0.4-cp314-cp314t-win32.whl", hash = "sha256:c6414837e375fd06f4664b276fcc3e76b17fa2431f650f52f0e41c48489d7e81"},
{file = "qh3-2.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:1923830f3e5e236bcf024ea317625346e5a3e1b08e7852e83293c1e4b3b8d7db"},
{file = "qh3-2.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:9d505d790ab10e378cf4e5c2d6447cb8b602d9cc36a4113160f9ece7bba9abc6"},
{file = "qh3-2.0.4-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ec087930c9b43d1e32c5c4cdc8d550fcb72cbfcc957c1d922859ce11b1e55663"},
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:406577d5fc72bed8d12b6397099b4e164948171dd0a16be8e768b31f1ef155ae"},
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e94ba4e929e0e54f615c4e28493980af5d82a6d928c644a2da26983df73e110"},
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:03015a711f8b1ab24825d6ac6d2af1874d4157ac886d236bc19f7a21e69377b6"},
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:fda466d648cebffccca1a68d9716b31d41eb09b284ecf17705b4a04991dbb881"},
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:06e8c7ad92fcb482de360af5ae00d8e4559e969d1559a5a88b68989c6bac22c4"},
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6c0907ab53bd1fb1cca0a309cf26df42c9862e43e40c79d0e5ac3d2b6b747689"},
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af34fad8554560fa38304f61708176379d5efb192da2ece5fb25dec23c304b36"},
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:b6760be9bc0401bf7e6c1e889181587619529f34ffcf3f8a8de5f074815dc2ab"},
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a29a0c05b7986ae0400a4229ca7176805cd44774675663b4ab0d4b6ebef7655c"},
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:3dcedaeaf791114a050762b097b18839f6cc491dbe83873137b080f7b6dd1411"},
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:8a8fbc4f30683a47dfb2b9eebc95e5f8b76c3a1eb818b0dd3354aad54c2d99f6"},
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:d777f66879bda1e2b73caa2ca624045362d3aab8b474d2db868d0f284822e24c"},
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1dad9bae44479f682cad2e0c0ea8cbd384ce42cd2921f1f1b4c03e1299f6acbc"},
{file = "qh3-2.0.4-cp37-abi3-win32.whl", hash = "sha256:45d57837ef9990c58c1776a12d551e7c88f2592a9885b6e781d30e18062b57c6"},
{file = "qh3-2.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:53f7180a74db23f5469a610a6bcdf2b29a4ed45e067a40276c83b1cead3816df"},
{file = "qh3-2.0.4-cp37-abi3-win_arm64.whl", hash = "sha256:79e93801a8f33e651a7751bde3e0e120b565a93d18d146bf1fc8127d1669494f"},
{file = "qh3-2.0.4.tar.gz", hash = "sha256:72b174c9dabfccdddf050dabc3c1b49972ed7a7564896d3db558e57376187f75"},
]
[[package]]
@@ -541,59 +566,83 @@ files = [
[[package]]
name = "ruff"
version = "0.15.22"
version = "0.16.10"
requires_python = ">=3.7"
summary = "An extremely fast Python linter and code formatter, written in Rust."
groups = ["dev"]
files = [
{file = "ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8"},
{file = "ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697"},
{file = "ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f"},
{file = "ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576"},
{file = "ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178"},
{file = "ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224"},
{file = "ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a"},
{file = "ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e"},
{file = "ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb"},
{file = "ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74"},
{file = "ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c"},
{file = "ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296"},
{file = "ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262"},
{file = "ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64"},
{file = "ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf"},
{file = "ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde"},
{file = "ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661"},
{file = "ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809"},
{file = "ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511"},
{file = "ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d"},
{file = "ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550"},
{file = "ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d"},
{file = "ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221"},
{file = "ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a"},
{file = "ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8"},
{file = "ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e"},
{file = "ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a"},
{file = "ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3"},
{file = "ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3"},
{file = "ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03"},
{file = "ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be"},
{file = "ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d"},
{file = "ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a"},
{file = "ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca"},
{file = "ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b"},
{file = "ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017"},
]
[[package]]
name = "sqlalchemy"
version = "2.0.54"
requires_python = ">=3.7"
version = "2.1.3"
requires_python = ">=3.11"
summary = "Database Abstraction Library"
groups = ["default"]
dependencies = [
"greenlet>=1; platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\"",
"importlib-metadata; python_version < \"3.8\"",
"typing-extensions>=4.6.0",
]
files = [
{file = "sqlalchemy-2.0.54-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:abd6b21bc58e91c1932eb5d6d7f1bd44a551dfec7b6a7f517c3638ccd67233a0"},
{file = "sqlalchemy-2.0.54-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5417322b3c025dd82918725d3bf09ec105fac95efc195722b8b06e1d9c381139"},
{file = "sqlalchemy-2.0.54-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f84099e4b04a5c2d44500a2a8302eee5af4bc6fee63e8c6e9cf6786e747280e"},
{file = "sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a0956dc754d3884da7fe60097110ec7a8a105d26afa2f0844468f4b1598c6912"},
{file = "sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:87ba8834318b0d8dc94fc6f405d071b5c08be32a6c3fd68107fd6952ee949615"},
{file = "sqlalchemy-2.0.54-cp314-cp314-win32.whl", hash = "sha256:842540e4382472f23c79589995752648d14696a8200d0807ed8c5c59c92ade44"},
{file = "sqlalchemy-2.0.54-cp314-cp314-win_amd64.whl", hash = "sha256:f4e8f955d13af83fb4e35c3472e5377ee22d3445eada1e5e48199588edb69835"},
{file = "sqlalchemy-2.0.54-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ca05f4e7852cf48083b0cf157e4f9504b7068780422a50fa82f45353b8c5e14a"},
{file = "sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a8b6417cbb7b735cf91c2b59453c2a554cefa0a8d7bd15aa35740739410d77"},
{file = "sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e55a0b96a1577a1e108c91ccdeeb9cd92768f28ce206597311c3bf6d6423abd"},
{file = "sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:69cab115c40fd02c5a22c68e4ee630fa6ef9a1650f1de944419aab1f7096fc4f"},
{file = "sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e08397c6c42f53b2488acde9108b8bfefd52d7afd1bf2f03d2ffcab7a204aceb"},
{file = "sqlalchemy-2.0.54-cp314-cp314t-win32.whl", hash = "sha256:b9086b8ad48280ef6a7ba68262d5e44f7db1c4cb1973e8cdae8a9f467ae66f51"},
{file = "sqlalchemy-2.0.54-cp314-cp314t-win_amd64.whl", hash = "sha256:b67c1744e453af833667fc1b84de07adb4a64f3536ef52a8ec5ac2b941d43970"},
{file = "sqlalchemy-2.0.54-py3-none-any.whl", hash = "sha256:7e33a631ab1474f8fe6b910bd1a07b7b8009c4c78cdd3fb18001b03e3bc2e1d2"},
{file = "sqlalchemy-2.0.54.tar.gz", hash = "sha256:baa8521e8ee9f24e75dfc7aaabc08020e551ef0d48d7c3e3536f5cddf277586b"},
{file = "sqlalchemy-2.1.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:660c6e16d254568874d4b77fd593aa5426f55cf40d0acc8b5364a5e61cc1044d"},
{file = "sqlalchemy-2.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7277ba31ffb636666ed207d2aaf74de3cd2ee78436da7552bd4eeadf1f67740a"},
{file = "sqlalchemy-2.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b728c406b1b202e8998e8f9adacb56773bfeea67da74252ad3025bcb8f5863f1"},
{file = "sqlalchemy-2.1.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:85623c57c3696dcbc4fc909bbf335a2f0c27ae36f762d83810067fb49abc040e"},
{file = "sqlalchemy-2.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0de6dc326cc99ac31206359e9fc2c13345f667edeb6ef556585fedac4ff698"},
{file = "sqlalchemy-2.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:65e002b794efa1bacbbde9fa973e5340d6c3c29b53e592b863a281802829319d"},
{file = "sqlalchemy-2.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65e4813c78eaa679c9369b289e67fdba924db6be14067700c5ccbda37953af9a"},
{file = "sqlalchemy-2.1.3-cp314-cp314-win32.whl", hash = "sha256:726987e7f8500614016be056ba5c9715b760ab465295741e2087b29753d72632"},
{file = "sqlalchemy-2.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:0560458a775917bcd7a47eccf4f9e0b135fd9b72d7b819cb1bd60581de946f9a"},
{file = "sqlalchemy-2.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:63c9289ed81991ba4d500611ce201531711430f5d23d7ae9a61a8842639b85ea"},
{file = "sqlalchemy-2.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:08b2f345db8b0742e7814c49f9a210d5f5c8fb9d80d12c8a63bba762fa277477"},
{file = "sqlalchemy-2.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:493b632f426579c4b0e97a5610f76c8c3d401cede442d462bf700b537035c91e"},
{file = "sqlalchemy-2.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e06933580f4faa5a7b1d2c9ad04f365fe01c40465eb982e38fda8758c1e95c"},
{file = "sqlalchemy-2.1.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fdcee9536cb3281b8810787ba651f4e5f22bd687b177de89fba2155b055b07b0"},
{file = "sqlalchemy-2.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d2cb28779dbf54067655030d7ab3a6726c7957c56bde5ff6745ed86b5cebff41"},
{file = "sqlalchemy-2.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:01a90cac3f8d93fcbda0df119ef56c4d58aad5601f6b7ee5a38bfabbd5d119b8"},
{file = "sqlalchemy-2.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:edbc28049ab8da07387ea2b85ceee747b36b25444ffa4aaba9f6e1461d5b2634"},
{file = "sqlalchemy-2.1.3-cp314-cp314t-win32.whl", hash = "sha256:724f587ced076d89291032b35476d6bdb401cce1dd680c1e3418c01d9bd55ad4"},
{file = "sqlalchemy-2.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:935085a5b8849d324c22bc3cc09fd07b3669cc22ba5bdd3ec208829e535cdef9"},
{file = "sqlalchemy-2.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2ebfff82cece947fea1ed6337d4f0613b9799f6db621c9729790fdd41edfb5ea"},
{file = "sqlalchemy-2.1.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:47a052dcd935a62326216bc5f0ed10ad941c77c3acc9200385e0c03c4f31d5a9"},
{file = "sqlalchemy-2.1.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e5916b43a9a8f346026f663583f203d0c43febad504ff2fed504dec6e078f20"},
{file = "sqlalchemy-2.1.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af78a85af63114e2650f019ddf8fffb871e659e92bffb7d0bce5753f706caf"},
{file = "sqlalchemy-2.1.3-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d218698c4f05728a941d6ad7c769b6491f03058e102cf811b7e4900f78fffa07"},
{file = "sqlalchemy-2.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e6ccf111bc3f57530935f5541c6c1da50ac9713c418378840683532a45dd2d"},
{file = "sqlalchemy-2.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9a3ce1ff88dfb9f9f4305da07f4fab87720b609e264d134e47ae84a52df130bd"},
{file = "sqlalchemy-2.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:77a365f7cb81bbd738c948b64e0b683f8f1b68efe51ca2815e8ad4baf6287ed9"},
{file = "sqlalchemy-2.1.3-cp315-cp315-win32.whl", hash = "sha256:83757320391b96cde715af0772675941d3e345da9ced3696c030f7f55ffa8eab"},
{file = "sqlalchemy-2.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:f87533295b84c9e19ca2a72dc26a76e0192f0a2424f8fd575a82e75e7d58f9e1"},
{file = "sqlalchemy-2.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:3e46a27b0c74af74aa6c08c983ca51db88d22dd86c316d72a634e989b7caef58"},
{file = "sqlalchemy-2.1.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:06ef75e3e0c72d774b41f659bfc4f63d5e207d6c8418abe2b80843a4a25e2fa6"},
{file = "sqlalchemy-2.1.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70e05ae07dd9be29d4723bd0c880df622387eb51efb46a7b8efd49295248ffd7"},
{file = "sqlalchemy-2.1.3-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10fdff11782842696172489bd4861ae63c212e62ee7c4fc61fdda90961abd1f3"},
{file = "sqlalchemy-2.1.3-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:edd3e6690f350c89c616e78e9b58833c3d8925607ac5faf3530f38f053f58e4d"},
{file = "sqlalchemy-2.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:e3c165df26eca3d2612f60ea91af02648cb1bd492eadfa64dba15c9a3a0fb09c"},
{file = "sqlalchemy-2.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:70c073d28d043766eb1725010792f302f59b14636ffef478793045471efe92dd"},
{file = "sqlalchemy-2.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:bf5f63229512f14e7bffc9a0041386318f592e76bbeaf9b750b39056094d91f3"},
{file = "sqlalchemy-2.1.3-cp315-cp315t-win32.whl", hash = "sha256:697942762c40e31eb0fa39bf0e58e1ac1b5b1ff9022da62b74f7cdc53096a482"},
{file = "sqlalchemy-2.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:a9c33c0d081a9d2c94229b9ae1834cb2ef81dd81253df4763e32111287b64c31"},
{file = "sqlalchemy-2.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:c36ff11196dacf50312a2cc51a798bb41611331c174871f065be651a838a5361"},
{file = "sqlalchemy-2.1.3-py3-none-any.whl", hash = "sha256:6f0cf4debd86cb5623a50310784bd005c215761ca7f618d0d4f46193d033675a"},
{file = "sqlalchemy-2.1.3.tar.gz", hash = "sha256:ade5281df06038c6394f532590d592d3381ee5d11b9e0006a2570aef41145118"},
]
[[package]]
@@ -620,7 +669,7 @@ files = [
[[package]]
name = "urllib3-future"
version = "2.25.900"
version = "2.25.902"
requires_python = ">=3.7"
summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces"
groups = ["default"]
@@ -630,8 +679,8 @@ dependencies = [
"qh3<3.0.0,>=1.5.4; (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\"",
]
files = [
{file = "urllib3_future-2.25.900-py3-none-any.whl", hash = "sha256:1d4809b9714aef7f35338b7d3a388b0280aefa41d2edb78ba48de91309d014c8"},
{file = "urllib3_future-2.25.900.tar.gz", hash = "sha256:0583bd313d223cb0d92367fe9bf07e7c8d926eebb0bdf6960670102e7ad4b6c1"},
{file = "urllib3_future-2.25.902-py3-none-any.whl", hash = "sha256:88e6f3c19f553931e78f84f011a34220f827c971e6cf06b3a075eaa788c2bf7d"},
{file = "urllib3_future-2.25.902.tar.gz", hash = "sha256:5c443668b9f87bce38bad1fdec7d7950c47e9c5b7328b1fd029a429741f3be70"},
]
[[package]]
+3 -3
View File
@@ -23,7 +23,7 @@ repos = [
types_or = ["python", "pyi"],
args = [],
require_serial = true,
additional_dependencies = ["ruff==0.15.22"],
additional_dependencies = ["ruff==0.16.10"],
},
{
id = "ruff-format",
@@ -32,7 +32,7 @@ repos = [
language = "python",
types_or = ["python", "pyi"],
require_serial = true,
additional_dependencies = ["ruff==0.15.22"],
additional_dependencies = ["ruff==0.16.10"],
},
{
id = "basedpyright",
@@ -41,7 +41,7 @@ repos = [
language = "python",
pass_filenames = false,
require_serial = true,
additional_dependencies = ["basedpyright==1.39.9"],
additional_dependencies = ["basedpyright==1.40.2"],
},
],
},
+7 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "plyngent"
version = "0.7.0"
version = "0.8.0"
description = "LLM chat and agent toolkit"
authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" },
@@ -27,8 +27,8 @@ plyngent = "plyngent.cli:main"
[dependency-groups]
dev = [
"basedpyright==1.39.9",
"ruff==0.15.22",
"basedpyright==1.40.2",
"ruff==0.16.10",
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
@@ -136,7 +136,11 @@ ignore = [
"COM812", # missing-trailing-comma
"PLC0415", # import-outside-top-level
"TID252",
# Same axis as C901 (conditional branches per function) but a looser default,
# and C901 flags every function it does — keep the one gate (see AGENTS.md).
"PLR0912", # too-many-branches
"PLR0913", # too-many-arguments (common on constructors/composed functions)
"PLR0917", # too-many-positional-arguments (same family as PLR0913 above)
]
allowed-confusables = [
",",
+100 -8
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
import contextlib
from contextvars import ContextVar, Token
from pathlib import Path
from typing import TYPE_CHECKING
import msgspec
from msgspec import UNSET
from plyngent.lmproto.openai_compatible.model import (
@@ -37,7 +39,7 @@ from .usage import TokenUsage
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
from plyngent.lmproto.openai_compatible.model import AnyAssistantToolCall, AnyChatMessage
from plyngent.memory import MemoryStore
from .events import AgentEvent
@@ -206,6 +208,78 @@ def committed_prefix_end(messages: Sequence[AnyChatMessage], user_index: int) ->
return end
def rollback_tail(messages: Sequence[AnyChatMessage], start: int) -> list[AnyChatMessage]:
"""Rows to keep from *start* when a failed turn is rolled back.
The trailing tool batch is committed only once every call has its result
(:func:`committed_prefix_end`), so a batch cut short — cancelled before the
last result landed, or ids that do not pair up exactly — would be dropped
whole and the retry would run calls that already had external effects. Keep
the executed ones: their assistant message stays as the carrier for the
pairing (API messages must answer every ``tool_calls`` id), without the text
of the unfinished turn, and the calls that never reported a result are
absent so the retry re-issues them instead of assuming they ran.
Developer rows (host notices, directive checkpoints) are already durable, so
they stay in place; unfinished assistant text and stray rows go.
"""
body = list(messages[start:])
kept: list[AnyChatMessage] = []
index = 0
while index < len(body):
message = body[index]
if isinstance(message, DeveloperChatMessage):
kept.append(message)
index += 1
continue
if isinstance(message, AssistantChatMessage) and message.tool_calls is not UNSET and message.tool_calls:
results: list[ToolChatMessage] = []
follow = index + 1
while follow < len(body):
following = body[follow]
if not isinstance(following, ToolChatMessage):
break
results.append(following)
follow += 1
kept.extend(_executed_tool_pair(message, message.tool_calls, results))
index = follow
continue
index += 1
return kept
def _executed_tool_pair(
assistant: AssistantChatMessage,
calls: Sequence[AnyAssistantToolCall],
results: Sequence[ToolChatMessage],
) -> list[AnyChatMessage]:
"""Assistant call record plus the results of the calls that produced one.
Pairs by call id (first result per id), so the history keeps the API's
one-result-per-call shape even when a model repeats an id. Text goes with
the rest of the unfinished turn; the reasoning stays, because thinking mode
refuses a tool call whose reasoning never came back (see
``responses_bridge`` / the DeepSeek chat client, which can only send an
empty part once the real chain-of-thought is gone).
"""
by_id: dict[str, ToolChatMessage] = {}
for result in results:
_ = by_id.setdefault(result.tool_call_id, result)
executed: list[AnyAssistantToolCall] = []
paired: list[AnyChatMessage] = []
seen: set[str] = set()
for call in calls:
if call.id in seen or call.id not in by_id:
continue
seen.add(call.id)
executed.append(call)
paired.append(by_id[call.id])
if not executed:
return []
record = msgspec.structs.replace(assistant, content=UNSET, tool_calls=executed)
return [record, *paired]
class ChatAgent:
"""Thin wrapper: chat client + optional tools + optional memory bind."""
@@ -216,6 +290,8 @@ class ChatAgent:
session_id: int | None
max_rounds: int
temperature: float | None
# Resolved thinking strength ("" = provider default); see config.reasoning.
reasoning_effort: str
on_limit: LimitContinueHook | None
stream: bool
system_prompt: str | None
@@ -247,6 +323,7 @@ class ChatAgent:
session_id: int | None = None,
max_rounds: int = DEFAULT_MAX_ROUNDS,
temperature: float | None = None,
reasoning_effort: str = "",
messages: Sequence[AnyChatMessage] | None = None,
on_limit: LimitContinueHook | None = None,
stream: bool = True,
@@ -269,6 +346,7 @@ class ChatAgent:
self.session_id = session_id
self.max_rounds = max_rounds
self.temperature = temperature
self.reasoning_effort = reasoning_effort
self.on_limit = on_limit
self.stream = stream
self.system_prompt = system_prompt
@@ -475,10 +553,20 @@ class ChatAgent:
msg = "nothing to retry"
raise RuntimeError(msg)
def _rollback_uncommitted(self, user_index: int) -> None:
"""Drop incomplete suffix; keep committed tool rounds after the user."""
async def _rollback_uncommitted(self, user_index: int) -> None:
"""Drop the unfinished suffix; keep committed rounds and executed tools.
Rows before the checkpoint cursor are already stored, so only the kept
suffix is written: a restart has to continue from the same tool results
the model is about to see, or the retry re-runs their effects.
"""
end = committed_prefix_end(self.messages, user_index)
del self.messages[end:]
self.messages[end:] = rollback_tail(self.messages, end)
if len(self.messages) > self._persist_from:
# A store error must not mask the failure being rolled back; the
# rows stay in memory, and retry() writes what is still unstored.
with contextlib.suppress(Exception):
await self._persist_range(self._persist_from, len(self.messages))
def _developer_tail_end(self, start: int) -> int:
"""Extend *start* through trailing developer checkpoint messages."""
@@ -492,7 +580,8 @@ class ChatAgent:
After each completed tool batch, commits assistant+tool messages to the
DB and keeps them on failure so :meth:`retry` continues without redoing
side-effecting tools. Unfinished assistant/stream suffix is rolled back.
side-effecting tools. Without such a batch, only the unfinished text is
dropped: calls that did report a result survive (:func:`rollback_tail`).
"""
user_index = self._user_index(user_msg)
if self.todo_stack is not None:
@@ -515,6 +604,7 @@ class ChatAgent:
tools=self.tools,
max_rounds=self.max_rounds,
temperature=self.temperature,
reasoning_effort=self.reasoning_effort,
on_limit=self.on_limit,
stream=self.stream,
max_tool_result_chars=self.max_tool_result_chars,
@@ -550,7 +640,7 @@ class ChatAgent:
completed = True
except BaseException:
if not completed:
self._rollback_uncommitted(user_index)
await self._rollback_uncommitted(user_index)
raise
self.last_turn_usage = turn_usage
@@ -658,6 +748,7 @@ class ChatAgent:
session_id=None,
max_rounds=rounds,
temperature=self.temperature,
reasoning_effort=self.reasoning_effort,
on_limit=self.on_limit,
stream=self.stream,
system_prompt=self.system_prompt if system_prompt is None else system_prompt,
@@ -687,7 +778,8 @@ class ChatAgent:
user_msg = self.messages[user_index]
assert isinstance(user_msg, UserChatMessage)
self._ensure_system_prompt()
# Already-committed messages stay; only new rounds are persisted.
self._persist_from = len(self.messages)
# Already-committed rows stay; write any the failed rollback could not.
if self._persist_from < len(self.messages):
await self._persist_range(self._persist_from, len(self.messages))
async for event in self._run_from_user_message(user_msg):
yield event
+41 -14
View File
@@ -57,7 +57,7 @@ if TYPE_CHECKING:
from plyngent.lmproto.anthropic.client import AnthropicClient
from plyngent.lmproto.openai.client import OpenAIClient
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem, ReasoningEffort
from .todo_nag import TodoNagStrategy
from .todo_stack import TodoStack
@@ -124,10 +124,14 @@ async def _execute_tool_calls(
else:
results = [await _run_one_tool(tools, call, max_result_chars=max_result_chars) for call in tool_calls]
# 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
# runs that tool a second time.
for tool_msg, _err in results:
messages.append(tool_msg)
for tool_msg, err in results:
if err is not None:
yield err
messages.append(tool_msg)
yield ToolResultEvent(message=tool_msg)
@@ -217,6 +221,7 @@ async def _dispatch_chat_completions(
return await dispatch_messages(
cast("AnthropicClient", client),
param,
provider_tools=provider_tools,
stream=stream,
)
msg = f"client kind {kind!r} is not supported by the agent loop"
@@ -255,16 +260,27 @@ async def _non_stream_round(
)
def _append_reasoning_delta(
def _take_reasoning_delta(
reasoning_parts: list[str],
delta: DeltaMessage,
) -> list[str]:
"""Accumulate a reasoning delta; a ``full`` (``.done``) event replaces fragments."""
if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
if delta.reasoning_full is True:
return [delta.reasoning_content]
return [*reasoning_parts, delta.reasoning_content]
return reasoning_parts
) -> tuple[list[str], list[ReasoningDeltaEvent]]:
"""Fold a reasoning delta into the chain of thought; say what to stream.
Fragments append to *reasoning_parts*; a ``full`` delta replaces them, since
DeepSeek's ``response.reasoning_text.done`` carries the whole chain of thought
its fragments spelled out. The display already printed those fragments, so a
full delta streams only a tail they did not cover — emitting it verbatim
printed the CoT twice — and is dropped when it does not continue them at all.
A round that streamed nothing streams the full text once.
"""
content = delta.reasoning_content
if not isinstance(content, str) or not content:
return reasoning_parts, []
if delta.reasoning_full is not True:
return [*reasoning_parts, content], [ReasoningDeltaEvent(content=content)]
streamed = "".join(reasoning_parts)
fresh = content.removeprefix(streamed) if content.startswith(streamed) else ""
return [content], [ReasoningDeltaEvent(content=fresh)] if fresh else []
async def _stream_round(
@@ -307,9 +323,12 @@ async def _stream_round(
finish_reason = fr
saw_terminal = True
delta = choice.delta
if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
reasoning_parts = _append_reasoning_delta(reasoning_parts, delta)
yield ReasoningDeltaEvent(content=delta.reasoning_content)
# A delta without reasoning folds to nothing, so every chunk can be taken
# here; the display sees only what it has not printed yet (see
# :func:`_take_reasoning_delta`).
reasoning_parts, reasoning_events = _take_reasoning_delta(reasoning_parts, delta)
for reasoning_event in reasoning_events:
yield reasoning_event
if isinstance(delta.content, str) and delta.content:
content_parts.append(delta.content)
yield TextDeltaEvent(content=delta.content)
@@ -406,7 +425,7 @@ async def _maybe_inject_directive_checkpoints(
return new_band
async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop
async def run_chat_loop( # noqa: C901 — multi-phase tool loop
client: AnyLLMClient,
messages: list[AnyChatMessage],
*,
@@ -414,6 +433,7 @@ async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop
tools: ToolRegistry | None = None,
max_rounds: int = DEFAULT_MAX_ROUNDS,
temperature: float | None = None,
reasoning_effort: str = "",
on_limit: LimitContinueHook | None = None,
stream: bool = True,
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
@@ -434,6 +454,10 @@ async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop
Multiple tool calls in one round run in parallel when ``parallel_tools``.
Request payloads may shrink older tool results when over ``max_context_tokens``.
*reasoning_effort* is the resolved thinking strength (``""`` = send nothing,
the provider's own default); each API surface maps it onto its own wire field
(see ``config.reasoning``).
*provider_tools* are hosted/provider-side tools (OpenAI Responses only, e.g.
``web_search``) merged into the request as opaque dicts; never executed by the
:class:`ToolRegistry`. Ignored by non-Responses clients.
@@ -478,6 +502,9 @@ async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop
model=model,
temperature=temperature if temperature is not None else UNSET,
tools=list(tool_items) if tool_items is not None else UNSET,
# "max" is DeepSeek-only; config.reasoning already clamped it away
# for every other surface, so this cast does not hide a mistake.
reasoning_effort=cast("ReasoningEffort", reasoning_effort) if reasoning_effort else UNSET,
)
pre_len = len(messages)
+122 -8
View File
@@ -18,7 +18,9 @@ from plyngent.lmproto.anthropic.model import (
AnthropicMessageResponse,
AnthropicMessagesParam,
AnthropicResponseText,
AnthropicResponseToolUse,
AnthropicTextContent,
AnthropicThinkingConfig,
AnthropicToolChoice,
AnthropicToolDefinition,
AnthropicToolResultContent,
@@ -68,11 +70,11 @@ def _encode_tool_arguments(obj: dict[str, Any]) -> str:
def tool_items_to_anthropic_tools(
tools: Sequence[AnyToolItem] | None,
) -> list[AnthropicToolDefinition]:
) -> list[AnthropicToolDefinition | dict[str, Any]]:
"""Map chat ``ToolFunctionItem`` list to Anthropic tool definitions."""
if not tools:
return []
result: list[AnthropicToolDefinition] = []
result: list[AnthropicToolDefinition | dict[str, Any]] = []
for item in tools:
if not isinstance(item, ToolFunctionItem):
continue
@@ -87,6 +89,35 @@ def tool_items_to_anthropic_tools(
return result
# The surface-neutral hosted-tool intent (``provider_tools`` default) as the
# Anthropic server tool it means; DeepSeek's compat and Claude both define the
# search tool this way, and both ignore an OpenAI-shaped ``web_search`` entry.
_ANTHROPIC_WEB_SEARCH_TOOL: dict[str, Any] = {"type": "web_search_20250305", "name": "web_search"}
def provider_tools_to_anthropic_tools(
provider_tools: Sequence[dict[str, Any]] | None,
) -> list[dict[str, Any]]:
"""Map hosted-tool dicts to Anthropic server tools (``[]`` when none).
Entries arrive in the OpenAI Responses shape the config defaults to, where a
bare ``web_search`` states the intent only; the Anthropic surface wants its
own definition, so that entry becomes
``{"type": "web_search_20250305", "name": "web_search"}`` — the server tool
Claude declares and DeepSeek's compat reads — keeping any other key (a
``max_uses`` cap, say). Anything else passes through as it is.
"""
result: list[dict[str, Any]] = []
for item in provider_tools or ():
entry = dict(item)
if entry.get("type") == "web_search":
extra = {key: value for key, value in entry.items() if key not in {"type", "name"}}
entry = {**_ANTHROPIC_WEB_SEARCH_TOOL, **extra}
if entry.get("type"):
result.append(entry)
return result
def _tool_choice_from_chat(param: ChatCompletionsParam) -> AnthropicToolChoice | Unset:
if param.tool_choice is UNSET:
return UNSET
@@ -189,7 +220,12 @@ def chat_messages_to_anthropic( # noqa: C901 — multi-role conversion
def anthropic_response_to_assistant(response: AnthropicMessageResponse) -> AssistantChatMessage:
"""Map a completed Anthropic message to agent ``AssistantChatMessage``."""
"""Map a completed Anthropic message to agent ``AssistantChatMessage``.
Server-side blocks (``server_tool_use`` / ``web_search_tool_result``) and
``thinking`` are not part of the chat-shaped message: a search the model ran
through its backend already shows up in the text it answered with.
"""
text_parts: list[str] = []
tool_calls: list[AnyAssistantToolCall] = []
for block in response.content:
@@ -197,7 +233,8 @@ def anthropic_response_to_assistant(response: AnthropicMessageResponse) -> Assis
if block.text:
text_parts.append(block.text)
continue
# AnthropicResponseToolUse (remaining content arm)
if not isinstance(block, AnthropicResponseToolUse):
continue
tool_calls.append(
AssistantFunctionToolCall(
id=block.id,
@@ -273,16 +310,93 @@ def _max_tokens_from_param(param: ChatCompletionsParam) -> int:
return 8192
def chat_param_to_anthropic_param(param: ChatCompletionsParam) -> AnthropicMessagesParam:
"""Build :class:`AnthropicMessagesParam` from a chat completions param."""
# Effort level → Anthropic ``thinking`` budget (an effort has no wire form there).
# Unknown levels fall back to "medium"; ``none`` is handled separately (disabled).
_ANTHROPIC_THINKING_BUDGETS: dict[str, int] = {
"minimal": 1024,
"low": 4096,
"medium": 8192,
"high": 16384,
"xhigh": 32768,
"max": 32768,
}
# Anthropic rejects ``budget_tokens < 1024`` and ``budget_tokens >= max_tokens``.
_ANTHROPIC_MIN_THINKING_BUDGET = 1024
# Headroom left for the answer when a thinking budget has to raise max_tokens.
_ANTHROPIC_MIN_ANSWER_TOKENS = 1024
def _thinking_from_param(
param: ChatCompletionsParam,
*,
thinking_budget_tokens: int,
) -> AnthropicThinkingConfig | Unset:
"""Build the ``thinking`` block for *param* (UNSET = send nothing).
``reasoning_effort = "none"`` disables thinking; any other level enables it
with the explicit *thinking_budget_tokens*, else with the budget that level
maps onto.
"""
effort = param.reasoning_effort
if effort is UNSET:
return UNSET
if effort == "none":
return AnthropicThinkingConfig(type="disabled")
fallback = _ANTHROPIC_THINKING_BUDGETS["medium"]
budget = thinking_budget_tokens or _ANTHROPIC_THINKING_BUDGETS.get(str(effort), fallback)
return AnthropicThinkingConfig(
type="enabled",
budget_tokens=max(_ANTHROPIC_MIN_THINKING_BUDGET, int(budget)),
)
def _thinking_and_max_tokens(
param: ChatCompletionsParam,
*,
thinking_budget_tokens: int,
) -> tuple[int, AnthropicThinkingConfig | Unset]:
"""Resolve the ``thinking`` block and the ``max_tokens`` that has to fit it.
Anthropic rejects ``budget_tokens >= max_tokens``; rather than silently
shrink a requested reasoning budget, raise ``max_tokens`` so the answer still
has room.
"""
max_tokens = _max_tokens_from_param(param)
thinking = _thinking_from_param(param, thinking_budget_tokens=thinking_budget_tokens)
if thinking is not UNSET and thinking.budget_tokens is not UNSET:
budget = int(thinking.budget_tokens)
if budget >= max_tokens:
max_tokens = budget + _ANTHROPIC_MIN_ANSWER_TOKENS
return max_tokens, thinking
def chat_param_to_anthropic_param(
param: ChatCompletionsParam,
*,
thinking_budget_tokens: int = 0,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> AnthropicMessagesParam:
"""Build :class:`AnthropicMessagesParam` from a chat completions param.
*thinking_budget_tokens* is the configured explicit ``thinking`` budget
(0 = derive it from the param's ``reasoning_effort``); *provider_tools* are
hosted tools (web_search, …) as opaque dicts, merged after local function
tools and never executed by the registry.
"""
system, messages = chat_messages_to_anthropic(param.messages)
tools = tool_items_to_anthropic_tools(param.tools if param.tools is not UNSET else None)
tools: list[AnthropicToolDefinition | dict[str, Any]] = list(
tool_items_to_anthropic_tools(param.tools if param.tools is not UNSET else None)
)
tools.extend(provider_tools_to_anthropic_tools(provider_tools))
max_tokens, thinking = _thinking_and_max_tokens(param, thinking_budget_tokens=thinking_budget_tokens)
kwargs: dict[str, Any] = {
"model": param.model,
"max_tokens": _max_tokens_from_param(param),
"max_tokens": max_tokens,
"messages": messages,
}
if thinking is not UNSET:
kwargs["thinking"] = thinking
if system:
kwargs["system"] = system
if tools:
+54 -10
View File
@@ -33,7 +33,7 @@ from plyngent.lmproto.anthropic.model import (
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Sequence
from plyngent.lmproto.anthropic.client import AnthropicClient
from plyngent.lmproto.anthropic.model import AnthropicStreamEvent
@@ -44,6 +44,11 @@ if TYPE_CHECKING:
)
def _thinking_budget(client: AnthropicClient) -> int:
"""Configured explicit ``thinking`` budget on *client* (0 = derive from effort)."""
return max(0, int(getattr(client, "thinking_budget_tokens", 0)))
class _ToolBlockState:
"""Tracks an in-flight tool_use content block during SSE streaming."""
@@ -61,11 +66,17 @@ class _ToolBlockState:
def _merge_usage(previous: dict[str, Any] | None, new: dict[str, Any]) -> dict[str, Any]:
"""Merge message_start input tokens with message_delta output tokens."""
"""Merge ``message_start`` usage with the later ``message_delta`` usage.
DeepSeek's compat reports a search turn's real prompt tokens only on the
delta (``message_start`` sees the question alone, the delta counts what the
search pulled in), so the larger of the two wins; output tokens are the
delta's cumulative count either way.
"""
if previous is None:
return new
prompt = int(previous.get("prompt_tokens", 0) or new.get("prompt_tokens", 0) or 0)
completion = int(new.get("completion_tokens", 0) or previous.get("completion_tokens", 0) or 0)
prompt = max(int(previous.get("prompt_tokens", 0) or 0), int(new.get("prompt_tokens", 0) or 0))
completion = max(int(previous.get("completion_tokens", 0) or 0), int(new.get("completion_tokens", 0) or 0))
return {
"prompt_tokens": prompt,
"completion_tokens": completion,
@@ -87,6 +98,7 @@ class _StreamState:
model: str
tool_blocks: dict[int, _ToolBlockState]
block_types: dict[int, str]
next_tool_index: int
stop_reason: str | None
last_usage: dict[str, Any] | None
@@ -96,6 +108,7 @@ class _StreamState:
def __init__(self, model: str) -> None:
self.model = model
self.tool_blocks = {}
self.block_types = {}
self.next_tool_index = 0
self.stop_reason = None
self.last_usage = None
@@ -121,11 +134,19 @@ class _StreamState:
return self.start_tool(block_index=block_index, call_id=f"toolu_{block_index}", name="")
def _handle_tool_use_start(
def _handle_block_start(
event: AnthropicContentBlockStart,
state: _StreamState,
) -> list[ChatCompletionChunk]:
"""Record the block's type; only ``tool_use`` becomes a local tool call.
``server_tool_use`` (a search the model's backend runs), its
``web_search_tool_result``, and ``thinking`` stream here too — recording
their type keeps the argument fragments that follow from being mistaken for
a local call's arguments (see ``_chunks_for_content_delta``).
"""
block = event.content_block
state.block_types[event.index] = block.type
if block.type != "tool_use":
return []
call_id = block.id or f"toolu_{event.index}"
@@ -165,7 +186,7 @@ def _chunks_for_event(event: AnthropicStreamEvent, state: _StreamState) -> list[
return []
if isinstance(event, AnthropicContentBlockStart):
return _handle_tool_use_start(event, state)
return _handle_block_start(event, state)
if isinstance(event, AnthropicContentBlockDelta):
return _chunks_for_content_delta(event, state)
@@ -194,6 +215,13 @@ def _chunks_for_content_delta(
if not is_json_delta:
return []
known_type = state.block_types.get(event.index)
if known_type is not None and known_type != "tool_use":
# A server tool's own arguments (DeepSeek streams the search query as
# ``input_json_delta`` on a ``server_tool_use`` block): nothing local
# asked for it, and its result arrived in this same response.
return []
tool_state = state.ensure_tool(event.index)
fragment = delta.partial_json if isinstance(delta.partial_json, str) else ""
if not fragment:
@@ -210,6 +238,8 @@ def _chunks_for_content_delta(
async def _stream_as_chat_chunks(
client: AnthropicClient,
param: ChatCompletionsParam,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> AsyncIterator[ChatCompletionChunk]:
"""Yield chat-completions chunks from an Anthropic Messages SSE stream.
@@ -219,7 +249,11 @@ async def _stream_as_chat_chunks(
``message_stop``. Errors raise ``RuntimeError`` so the agent loop can
surface them as retryable failures.
"""
create = chat_param_to_anthropic_param(param)
create = chat_param_to_anthropic_param(
param,
thinking_budget_tokens=_thinking_budget(client),
provider_tools=provider_tools,
)
stream = await client.messages(create, stream=True)
state = _StreamState(param.model)
@@ -246,12 +280,22 @@ async def dispatch_messages(
client: AnthropicClient,
param: ChatCompletionsParam,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
stream: bool = False,
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
"""Run one Anthropic Messages turn and return a chat-completions-shaped result."""
create = chat_param_to_anthropic_param(param)
"""Run one Anthropic Messages turn and return a chat-completions-shaped result.
*provider_tools* are hosted tools (web_search, …) as opaque dicts; the
surface's own server-tool definition is built from them and the results
arrive inside the same response, so nothing is executed locally.
"""
create = chat_param_to_anthropic_param(
param,
thinking_budget_tokens=_thinking_budget(client),
provider_tools=provider_tools,
)
if stream:
return _stream_as_chat_chunks(client, param)
return _stream_as_chat_chunks(client, param, provider_tools=provider_tools)
response = await client.messages(create, stream=False)
return anthropic_response_to_chat_completion(response)
+80 -27
View File
@@ -16,6 +16,7 @@ from plyngent.lmproto.openai.model import (
ResponseEasyInputMessage,
ResponseFunctionTool,
ResponseFunctionToolCallOutput,
ResponseReasoningConfig,
response_function_calls,
response_output_text,
)
@@ -40,6 +41,15 @@ if TYPE_CHECKING:
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
from plyngent.typedef import Unset
# A round that kept no chain-of-thought still goes back with a ``reasoning``
# item, holding a blank line: thinking mode pairs every round of the turn being
# continued with its reasoning and rejects both a missing item and an empty
# ``reasoning_text`` — "The ``reasoning_text`` in the thinking mode must be
# passed back to the API" — while any non-empty text is accepted. A blank part
# says nothing the round did not say; a line of prose here would put the
# bridge's words in the model's own mouth.
_BLANK_REASONING = " "
def tool_items_to_response_tools(
tools: Sequence[AnyToolItem] | None,
@@ -63,11 +73,44 @@ def tool_items_to_response_tools(
return result
def _reasoning_item(text: str) -> dict[str, Any]:
"""A ``reasoning`` input item holding *text* as its chain-of-thought."""
return {"type": "reasoning", "content": [{"type": "reasoning_text", "text": text}]}
def _assistant_to_input_items(
message: AssistantChatMessage,
) -> list[dict[str, Any] | ResponseEasyInputMessage]:
"""Map one assistant message to Responses ``input`` items.
A round's items come back in the order the model produced them: the
reasoning, then the text, then the calls. The ``reasoning`` item always
holds text — the API merges it into the assistant message its round forms,
and the turn being continued must return every round's chain-of-thought
("必须完整回传 ``reasoning_content`` … 即使该轮模型未实际进行工具调用") — and
a round that kept none sends a blank line, because an empty
``reasoning_text`` and a missing item are both answered with a 400.
History holds rounds with no chain-of-thought at all — a ``rollback_tail``
carrier, a forged ``todo_list`` call, the seed a compacted session starts
with, rounds recorded before the reasoning was read back.
The part is ``reasoning_text``, the only content part the API documents for
``reasoning`` items (``summary`` and ``encrypted_content`` are unsupported).
"""
items: list[dict[str, Any] | ResponseEasyInputMessage] = []
if message.tool_calls is not UNSET and message.tool_calls:
raw_reasoning = message.reasoning_content
reasoning = raw_reasoning if isinstance(raw_reasoning, str) else ""
tool_calls = message.tool_calls
calls = (
[call for call in tool_calls if isinstance(call, AssistantFunctionToolCall)] if tool_calls is not UNSET else []
)
content = message.content
text = content if isinstance(content, str) and content else None
if calls or text is not None or reasoning:
items.append(_reasoning_item(reasoning or _BLANK_REASONING))
if text is not None:
items.append(ResponseEasyInputMessage(role="assistant", content=text))
items.extend(
{
"type": "function_call",
@@ -75,11 +118,8 @@ def _assistant_to_input_items(
"name": call.function.name,
"arguments": call.function.arguments,
}
for call in message.tool_calls
if isinstance(call, AssistantFunctionToolCall)
for call in calls
)
if isinstance(message.content, str) and message.content:
items.append(ResponseEasyInputMessage(role="assistant", content=message.content))
return items
@@ -95,17 +135,20 @@ def _item_call_id(
return None
def _interleave_tool_outputs(
def _group_tool_outputs(
items: list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput],
) -> list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput]:
"""Place each ``function_call_output`` immediately after its ``function_call``.
"""Place a round's ``function_call_output`` items after all of its calls.
OpenAI matches outputs to calls by ``call_id`` anywhere in ``input``, but
DeepSeek's Responses implementation expects each ``function_call`` to be
directly followed by its ``function_call_output`` — a parallel batch sent
as ``call, call, output, output`` is rejected with "No tool output found
for tool call …". Interleaving is a no-op for single-call rounds and stays
valid for OpenAI (outputs still follow their calls).
The API reads a round as the assistant message its ``reasoning`` and
``function_call`` items merge into, so a round's calls must stay contiguous:
sent as ``call, output, call, output``, the second call becomes a round of
its own without reasoning and the request is answered with "The
``reasoning_text`` in the thinking mode must be passed back to the API".
Grouping also keeps each output next to its own call when another item (a
developer notice) was recorded between them — an output that trails an
unrelated assistant message is rejected with "No tool output found for tool
call …".
"""
outputs_by_call: dict[str, list[ResponseFunctionToolCallOutput]] = {}
for item in items:
@@ -113,16 +156,22 @@ def _interleave_tool_outputs(
outputs_by_call.setdefault(item.call_id, []).append(item)
out: list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput] = []
pending: list[str] = []
for item in items:
if isinstance(item, ResponseFunctionToolCallOutput):
continue # re-added right after their call below
out.append(item)
continue # re-added after the round's last call below
call_id = _item_call_id(item)
if call_id is not None and call_id in outputs_by_call:
out.extend(outputs_by_call.pop(call_id))
if call_id is None:
out.extend(output for pending_id in pending for output in outputs_by_call.pop(pending_id, []))
pending.clear()
out.append(item)
if call_id is not None:
pending.append(call_id)
for pending_id in pending:
out.extend(outputs_by_call.pop(pending_id, []))
# Defensive: outputs whose call was not seen keep stream order at the end.
for pending in outputs_by_call.values():
out.extend(pending)
for pending_outputs in outputs_by_call.values():
out.extend(pending_outputs)
return out
@@ -155,7 +204,7 @@ def chat_messages_to_responses_input(
)
instructions = "\n\n".join(instructions_parts) if instructions_parts else None
return instructions, _interleave_tool_outputs(items)
return instructions, _group_tool_outputs(items)
def response_to_assistant_message(response: Response) -> AssistantChatMessage:
@@ -188,7 +237,7 @@ def _reasoning_text_blocks(raw: object) -> list[str]:
if not isinstance(block_obj, dict):
continue
block_map = cast("dict[str, object]", block_obj)
if block_map.get("type") in {"summary_text", "output_text", "reasoning_content"}:
if block_map.get("type") in {"summary_text", "output_text", "reasoning_text", "reasoning_content"}:
text = block_map.get("text")
if isinstance(text, str) and text:
parts.append(text)
@@ -196,12 +245,14 @@ def _reasoning_text_blocks(raw: object) -> list[str]:
def reasoning_summary_text(response: Response) -> str:
"""Concatenate reasoning text from the response.
"""Concatenate the chain-of-thought text of a completed response.
OpenAI puts ``reasoning`` items in ``output`` with a ``summary`` block list.
DeepSeek (Responses convention) additionally returns the full chain-of-thought
in the top-level ``response.reasoning`` (``content``/``summary`` block lists);
prefer it when present so the two sources are not duplicated.
A reasoning item carries it in ``content`` — ``reasoning_text`` parts, with
``summary`` left empty (DeepSeek's documented shape; OpenAI streams a
summary instead). DeepSeek (Responses convention) may also return the full
chain-of-thought in the top-level ``response.reasoning`` (``content`` /
``summary`` block lists); prefer it when present so the two sources are not
duplicated.
"""
reasoning = response.reasoning
if isinstance(reasoning, dict):
@@ -212,7 +263,7 @@ def reasoning_summary_text(response: Response) -> str:
for raw in response.output:
if raw.get("type") != "reasoning":
continue
parts.extend(_reasoning_text_blocks(raw.get("summary")))
parts.extend(_reasoning_text_blocks(raw.get("content")) + _reasoning_text_blocks(raw.get("summary")))
return "".join(parts)
@@ -336,6 +387,8 @@ def chat_param_to_responses_kwargs(
kwargs["max_output_tokens"] = param.max_tokens
if param.parallel_tool_calls is not UNSET:
kwargs["parallel_tool_calls"] = param.parallel_tool_calls
if param.reasoning_effort is not UNSET:
kwargs["reasoning"] = ResponseReasoningConfig(effort=param.reasoning_effort)
if param.tool_choice is not UNSET and isinstance(param.tool_choice, str):
kwargs["tool_choice"] = param.tool_choice
return kwargs
+1 -1
View File
@@ -107,7 +107,7 @@ class TodoStack:
return self._data
@classmethod
def from_raw(cls, raw: object | None) -> TodoStack: # noqa: C901, PLR0911
def from_raw(cls, raw: object | None) -> TodoStack: # noqa: C901, PLR0911 — three stored stack shapes
if raw is None or not isinstance(raw, dict):
return cls()
blob = cast("dict[str, object]", raw)
+1 -1
View File
@@ -218,7 +218,7 @@ async def _run_oneshot(state: ReplState, prompt_text: str) -> int:
return EXIT_OK if ok else EXIT_TURN_FAILED
async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
async def _run_chat( # noqa: C901, PLR0915 — chat orchestration
*,
config_path: Path | None,
provider_name: str | None,
+130 -26
View File
@@ -181,7 +181,11 @@ class PrettyLine:
``prefix`` is written as soon as the call starts — a blocking tool call is
then visible while it runs — and ``detail`` is appended once its result
lands. Together they equal the single line the whole-line path prints.
lands, so the two land on one line. Together they equal the single line the
whole-line path prints. A later call of the same round waits its turn (see
:class:`_PrettyToolStream`); only when *other* output takes the line — a
confirm/ask prompt — does the prefix stay as its own line and the result
print as a complete line instead.
"""
prefix: Callable[[str], str]
@@ -221,6 +225,8 @@ _TODO_LABELS: dict[str, str] = {
"todo_clear": "Todo Clear",
}
_TODO_TOOLS: frozenset[str] = frozenset(_TODO_LABELS)
def _todo_line(name: str) -> PrettyLine:
"""Todo tools: header first, the rendered stack as the detail."""
@@ -235,6 +241,18 @@ def _todo_line(name: str) -> PrettyLine:
return PrettyLine(prefix=prefix, detail=detail)
def _echo_todo_batch(labels: list[str], detail: str) -> None:
"""One line for a batch of todo calls, then the last result (its stack).
A model that updates its list in one round used to print one header and a
full stack render per call; the last result already carries the final stack,
so one summary line plus that result says the same thing in fewer rows.
"""
ops = ", ".join(label.removeprefix("Todo ") for label in labels)
click.secho(f"\n* Todo x{len(labels)}: {ops}", fg="yellow")
click.echo(detail)
def _tree_line_stats(fmt: str, line: str) -> tuple[str, int] | None:
"""Classify one ``tree`` result line as ``(kind, depth)`` or None (not an entry).
@@ -405,6 +423,27 @@ def _fetch_detail(_args_json: str, result: str) -> str:
return _pretty_segments((f"({status or 'done'})", _http_status_fg(status)))
# ``web_search`` reports "every source found nothing (or failed)" as an error, so
# the renderer splits that emptiness off the failures that are real errors.
_WEB_SEARCH_EMPTY = "error: the search found nothing"
def _web_search_prefix(args_json: str) -> str:
query = _json_str_arg(args_json, "query") or "?"
return _pretty_prefix(f"* Web Search '{_preview(query, _ASK_SUBJECT_PREVIEW)}' ")
def _web_search_detail(_args_json: str, result: str) -> str:
"""``web_search`` outcome: hit count, nothing found, or a failing source."""
if result.startswith(_WEB_SEARCH_EMPTY):
return _pretty_segments(("(no results)", "dim"))
if result.startswith("error:"):
return _pretty_segments((f"({result})", "red"))
hits = sum(1 for line in result.splitlines() if line.startswith("- "))
unit = "hit" if hits == 1 else "hits"
return _pretty_segments((f"({hits} {unit})", None))
_MUTATOR_VERBS: dict[str, str] = {
"edit_replace": "Edit",
"edit_lineno": "Edit",
@@ -807,6 +846,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),
"web_search": PrettyLine(prefix=_web_search_prefix, detail=_web_search_detail),
"edit_replace": _mutator_line("edit_replace"),
"edit_lineno": _mutator_line("edit_lineno"),
"write_file": _mutator_line("write_file"),
@@ -867,14 +907,14 @@ def _echo_stream(text: str) -> None:
_ = sys.stdout.flush()
def _clear_streamed_lines(line_count: int, *, resume_above: bool = False) -> None:
def _clear_streamed_lines(line_count: int) -> None:
"""Clear exactly *line_count* terminal lines ending at the cursor line.
The cursor ends at column 0 of the topmost cleared line, so a re-render
overwrites the erased block in place instead of appending below it. With
``resume_above`` the cursor steps one line further up: the next
``"\\n"``-prefixed write then reuses the last cleared line — needed by the
parallel-batch fallback, which must not touch the line above the prefix.
overwrites the erased block in place instead of appending below it. Only the
assistant markdown re-render uses this: pretty tool lines never move the
cursor, because an interrupted prefix is *closed* as its own line instead
(see :class:`_PrettyToolStream`).
``line_count`` counts *physical* rows; callers fold wrapped lines via
:func:`_line_count_for_clear`.
@@ -885,8 +925,6 @@ def _clear_streamed_lines(line_count: int, *, resume_above: bool = False) -> Non
for _ in range(line_count - 1):
_ = sys.stdout.write("\r\033[2K\033[1A")
_ = sys.stdout.write("\r\033[2K")
if resume_above:
_ = sys.stdout.write("\033[1A")
with contextlib.suppress(OSError):
_ = sys.stdout.flush()
@@ -926,37 +964,38 @@ class _PrettyToolStream:
Writing the prefix immediately keeps a blocking tool call visible; appending
the detail later reproduces the single whole-line write byte for byte, so
non-interactive output is unchanged. The calls of one batch are yielded
before their results, so a second call while a prefix is open (parallel
tools) cannot append its detail in order: that prefix is erased and the
batch falls back to whole lines.
non-interactive output is unchanged. Nothing ever moves the cursor: a round
that issues several calls holds the prefixes of the later ones until the open
row takes its own detail (results arrive in call order), so every call ends
up with exactly one finished row. Output that genuinely takes the line — a
confirm/ask prompt, streamed text, an error — closes the open prefix as its
own line instead and the rest of the batch prints whole lines; a prefix wider
than the terminal thus degrades cleanly rather than leaving a half-erased row
behind.
"""
_interactive: bool
_open: bool
_whole_lines: bool
_queued: list[str]
def __init__(self, *, interactive: bool) -> None:
self._interactive = interactive
self._open = False
self._whole_lines = False
self._queued = []
def start_call(self, prefix: str) -> None:
"""Show *prefix* for a call that is about to run (cursor left mid-line)."""
if not (self._interactive and prefix) or self._whole_lines:
return
if self._open:
# Parallel batch: the open prefix could never receive its detail.
# Erase only the prefix line — the line above may be streamed
# reasoning — and let the fallback's leading newline reuse it.
_clear_streamed_lines(1, resume_above=True)
self._open = False
self._whole_lines = True
# Sibling call of the same round: hold its prefix back instead of
# closing the open row, which would leave the earlier call as an
# abandoned half-line and repeat its prefix on the whole line.
self._queued.append(prefix)
return
click.echo(f"\n{prefix}", nl=False)
with contextlib.suppress(OSError):
_ = sys.stdout.flush()
self._open = True
self._show_prefix(prefix)
def finish_call(self, detail: str) -> bool:
"""Append *detail* to the open prefix; False when a whole line is needed."""
@@ -964,21 +1003,58 @@ class _PrettyToolStream:
return False
click.echo(f"{detail}\n", nl=False)
self._open = False
_mark_open_prefix(None)
if self._queued:
# This row is complete, so the next call of the batch can have its own.
self._show_prefix(self._queued.pop(0))
return True
def close_line(self) -> None:
"""End a dangling prefix line before other output takes the next line."""
self._queued.clear()
if not self._open:
return
click.echo()
self._open = False
self._whole_lines = True
_mark_open_prefix(None)
def end_batch(self) -> None:
"""Reset batch-scoped state once every call of the batch has a result."""
self.close_line()
self._whole_lines = False
def _show_prefix(self, prefix: str) -> None:
"""Write *prefix* and leave the cursor on it for its own detail."""
click.echo(f"\n{prefix}", nl=False)
with contextlib.suppress(OSError):
_ = sys.stdout.flush()
self._open = True
_mark_open_prefix(self)
# The stream with a prefix line still open (cursor mid-line), or None. A confirm
# box or ``ask_user_*`` prompt is printed from a worker thread while the tool
# runs, so it needs a way to end that line before writing its own.
_open_prefix_stream: _PrettyToolStream | None = None
def _mark_open_prefix(stream: _PrettyToolStream | None) -> None:
global _open_prefix_stream # noqa: PLW0603
_open_prefix_stream = stream
def close_open_pretty_line() -> None:
"""End an open pretty prefix line so other output starts on its own line.
Wired into the prompt backend (:func:`plyngent.prompting.set_prompt_output_hook`):
without it a confirm/ask prompt raised while a tool runs is written onto the
still-open prefix line, and the result would later be appended to that mess.
"""
stream = _open_prefix_stream
if stream is not None:
stream.close_line()
def print_markdown(text: str, *, label: str = "assistant:") -> None:
"""Render *text* as markdown via Rich; *label* on its own line when set."""
@@ -1007,7 +1083,7 @@ def _flush_assistant_markdown(body: str, *, pretty: bool) -> None:
click.echo()
async def render_events( # noqa: C901, PLR0912, PLR0915
async def render_events( # noqa: C901, PLR0915 — one branch per event type
events: AsyncIterator[AgentEvent],
*,
verbose: bool | None = None,
@@ -1022,7 +1098,8 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
Pretty tool calls print their ``* Verb 'target' `` prefix as soon as the call
starts, so a blocking tool is visible while it runs; the outcome is appended
when the result lands (same bytes as the whole-line fallback).
when the result lands (same bytes as the whole-line fallback). A round that
issues several calls renders them in call order, one finished row each.
"""
show_full = get_verbose_tool_results() if verbose is None else verbose
use_markdown = get_markdown_enabled() if markdown is None else markdown
@@ -1036,6 +1113,12 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
# Tool calls buffer so prettified tools can render once their result lands
# (the summary line needs the status/range). FIFO matches loop event order.
pending_tools: list[tuple[str, str, PrettyLine | None]] = []
# A batch (one round's tool calls) that only touches todos prints one summary
# line plus its last result, whose text carries the final stack. Collected as
# the labels seen so far and that result's detail, flushed when the batch ends.
todo_labels: list[str] = []
merged_todo = False
merged_detail: str | None = None
def flush_assistant() -> None:
nonlocal source, assistant_buf, printed_assistant
@@ -1089,14 +1172,25 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
name = call.function.name
args = call.function.arguments
line = _pretty_line_for(name)
if not pending_tools:
# First call of a batch: a batch that only touches todos is
# printed as one merged block once its last result lands.
todo_labels.clear()
merged_todo = name in _TODO_TOOLS
pending_tools.append((name, args, line))
if line is not None:
if name in _TODO_TOOLS:
if merged_todo:
todo_labels.append(_TODO_LABELS[name])
elif line is not None:
merged_todo = False
tool_lines.start_call(line.prefix(args))
else:
merged_todo = False
tool_lines.close_line()
preview = _preview(args, _TOOL_ARGS_PREVIEW)
click.secho(f"\n[tool] {name}({preview})", fg="yellow")
else:
merged_todo = False
tool_lines.close_line()
pending_tools.append(("custom", call.id, None))
click.secho(f"\n[tool] custom id={call.id}", fg="yellow")
@@ -1107,7 +1201,14 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
parts = _pretty_parts(line, args, content) if line is not None else None
if parts is not None and not show_full:
prefix, detail = parts
if not tool_lines.finish_call(detail):
if name in _TODO_TOOLS:
if merged_todo and len(todo_labels) > 1:
# Merged batch: the batch end prints the last result.
merged_detail = detail
else:
tool_lines.close_line()
click.echo(f"\n{prefix}{detail}")
elif not tool_lines.finish_call(detail):
click.echo(f"\n{prefix}{detail}")
elif show_full:
tool_lines.close_line()
@@ -1117,6 +1218,9 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
preview = _preview_result(content, _TOOL_RESULT_PREVIEW)
click.secho(f"[tool ok] {preview}", fg="magenta")
if not pending_tools:
if merged_detail is not None:
_echo_todo_batch(todo_labels, merged_detail)
merged_detail = None
tool_lines.end_batch()
elif isinstance(event, ErrorEvent):
flush_assistant()
+4 -1
View File
@@ -37,6 +37,9 @@ _MINIMAL_CONFIG = """\
# confirm_destructive = true
# path_denylist = ["/secrets/", ".ssh/"]
# max_context_tokens = 200000
# # Thinking strength: "" (provider default) | none | minimal | low | medium |
# # high | xhigh | max; providers and models may override it.
# # reasoning_effort = "medium"
#
# # Optional compact prompts (empty = use built-in defaults).
# # compact_system_prompt = ""
@@ -54,7 +57,7 @@ _MINIMAL_CONFIG = """\
# [providers.deepseek]
# preset = "deepseek"
# access_key_or_token = "sk-..."
# # models default to deepseek-v4-flash and deepseek-v4-pro if omitted
# # models default to deepseek-flash and deepseek-v4-pro if omitted
"""
+3
View File
@@ -7,6 +7,7 @@ import sys
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
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 (
ChoiceOption,
@@ -18,6 +19,7 @@ from plyngent.prompting import (
configure_prompting,
confirm,
get_prompt_backend,
set_prompt_output_hook,
)
from plyngent.tools.access import AccessDecision
from plyngent.tools.process.pty_session import PtyManager
@@ -580,4 +582,5 @@ def install_cli_limit_hooks() -> None:
process-global hook).
"""
configure_prompting(pause_factory=off_loop_prompt)
set_prompt_output_hook(close_open_pretty_line)
PtyManager.set_limit_continue_hook(prompt_continue_limit)
+6 -5
View File
@@ -112,9 +112,10 @@ HISTORY_ROW_NUMBER = HistoryRowNumberType()
HELP_FOOTER = (
"User messages are saved immediately. On API errors or Ctrl+C, partial\n"
"assistant/tool output is discarded but the user message stays (so /retry\n"
"works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n"
"User messages are saved immediately. On API errors or Ctrl+C, unfinished\n"
"assistant text is discarded (tool results that already ran stay, so /retry\n"
"does not re-run them) but the user message stays (so /retry works after\n"
"resume, not only via readline history). Auto-retry: 5s/10s/15s/20s, then +10s.\n"
"\n"
"Side questions: /btw … answers without changing the main session\n"
"(--tools=read by default: read-only tools; --tools no/full opt-in; --fresh).\n"
@@ -851,7 +852,7 @@ def provider_cmd(state: ReplState, name: str | None) -> None:
help="Merge remote (and recovered) model ids into plyngent.toml for this provider.",
)
@click.pass_obj
def models_cmd(state: ReplState, *, refresh: bool, persist: bool) -> None: # noqa: C901
def models_cmd(state: ReplState, *, refresh: bool, persist: bool) -> None: # noqa: C901 — list / recover / persist
"""List models (remote-first, plus config-only ids). Always tries GET /models."""
del refresh # always re-fetch; flag kept for CLI compatibility / docs
remote: list[str] | None = None
@@ -1511,7 +1512,7 @@ def history_cmd(
@click.argument("action", required=False, type=TODOS_ACTION)
@click.argument("rest", required=False, nargs=-1, type=str)
@click.pass_obj
def todos_cmd( # noqa: C901, PLR0911, PLR0912, PLR0915
def todos_cmd( # noqa: C901, PLR0911, PLR0915 — one branch per /todos action
state: ReplState, action: str | None, rest: tuple[str, ...]
) -> None:
"""Show or edit the LIFO stack of **task groups**.
+60 -4
View File
@@ -13,7 +13,9 @@ from plyngent.agent.todo_stack import TodoStack
if TYPE_CHECKING:
from datetime import datetime
from plyngent.agent.tools import ToolDefinition
from plyngent.agent.types import AnyLLMClient
from plyngent.tools.net.search import SearchBackend
from plyngent.cli.models_source import (
DEFAULT_MODELS_CACHE_TTL,
client_supports_models,
@@ -21,7 +23,7 @@ from plyngent.cli.models_source import (
fetch_remote_model_ids,
model_choices_for_provider,
)
from plyngent.config import EffectiveProvider, resolve_effective_provider
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.tools import InstanceState, SessionState
@@ -101,7 +103,7 @@ class ReplState:
_remote_models_error: str | None = field(default=None, init=False, repr=False)
def __post_init__(self) -> None:
self.client = create_client(self.provider, model=self.model)
self.client = create_client(self.provider, model=self.model, reasoning=self.reasoning)
self.workspace = Path(self.workspace).expanduser().resolve()
self.instance_state.workspace_root = self.workspace
self.instance_state.workspace.root = self.workspace
@@ -398,7 +400,7 @@ class ReplState:
tools = catalog.select(surface="local", exclude_names=excluded)
yolo = self.effective_yolo() != "off"
# Always attach soft-confirm path so non-YOLO tools still prompt under YOLO mode.
return ToolRegistry(
registry = ToolRegistry(
tools,
danger=classify_danger,
on_confirm=prompt_confirm_tool_async,
@@ -407,6 +409,51 @@ class ReplState:
instance_state=self.instance_state,
session_state=self.session_state,
)
search_tool = self._web_search_tool()
if search_tool is not None:
registry.register(search_tool)
return registry
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.tools.net.bing import BingSearchBackend
from plyngent.tools.net.search import (
DeepseekSearchBackend,
build_web_search_tool,
)
provider = self.effective_provider
if not local_web_search(provider) or not self.model:
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,
)
)
elif source == "bing":
backends.append(BingSearchBackend())
if not backends:
return None
return build_web_search_tool(backends)
def _mcp_instructions_text(self) -> str:
"""Compose connected MCP servers' initialize ``instructions`` into one block.
@@ -447,6 +494,7 @@ class ReplState:
memory=self.memory,
session_id=self.session_id,
max_rounds=self.max_rounds,
reasoning_effort=self.reasoning.effort,
on_limit=on_limit,
stream=self.stream_enabled,
system_prompt=system_prompt,
@@ -472,7 +520,7 @@ class ReplState:
# Preserve live stream toggle if agent already exists.
if hasattr(self, "agent"):
self.stream_enabled = self.agent.stream
self.client = create_client(self.provider, model=self.model)
self.client = create_client(self.provider, model=self.model, reasoning=self.reasoning)
self.agent = self._make_agent()
# Restore history without re-marking already-stored messages as dirty.
self.agent.replace_messages(messages, persist_from=persist_from)
@@ -493,6 +541,14 @@ class ReplState:
"""Selected provider after model-level preset/url overrides."""
return resolve_effective_provider(self.provider, model=self.model)
@property
def reasoning(self) -> ReasoningConfig:
"""Merged thinking settings for the selected provider + model.
``[agent]`` < provider < model; see :mod:`plyngent.config.reasoning`.
"""
return resolve_reasoning(self.config.agent_config, self.provider, self.model)
def invalidate_remote_models(self) -> None:
"""Drop cached remote model catalog (provider/client change)."""
self._remote_models = None
+3
View File
@@ -21,10 +21,13 @@ from .models import OpenAICompatibleProvider as OpenAICompatibleProvider
from .models import OpenAIProvider as OpenAIProvider
from .models import PluginsConfig as PluginsConfig
from .models import ProviderConfig as ProviderConfig
from .models import ReasoningEffortConfig as ReasoningEffortConfig
from .models import SkillDiscoverySource as SkillDiscoverySource
from .models import SkillsConfig as SkillsConfig
from .models import compose_agent_system_content as compose_agent_system_content
from .path import get_default_path as _get_default_path
from .reasoning import ReasoningConfig as ReasoningConfig
from .reasoning import resolve_reasoning as resolve_reasoning
from .routing import EffectiveProvider as EffectiveProvider
from .routing import ProviderPreset as ProviderPreset
from .routing import default_url_for_preset as default_url_for_preset
+49 -4
View File
@@ -9,10 +9,20 @@ 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``).
type SearchSource = Literal["deepseek", "bing"]
# Static directory pre-allow mode for ``[agent].allow_paths`` (path → mode).
type AllowPathMode = Literal["read", "write", "exec"]
# Thinking strength, normalized across API surfaces (``reasoning_effort`` on
# ``[agent]``, a provider, or a single model; the most specific one wins).
# "" = send nothing and let the provider pick its own default.
# Mapped per surface by ``config.reasoning``: chat completions / Responses send
# ``reasoning_effort`` / ``reasoning.effort`` (``max`` is DeepSeek-only and
# clamps to ``xhigh`` elsewhere), Anthropic maps it onto ``thinking``.
type ReasoningEffortConfig = Literal["", "none", "minimal", "low", "medium", "high", "xhigh", "max"]
# Built-in skill discovery sources (``[skills].discover``): our own user config
# directory, and the skill directories other harnesses install into.
type SkillDiscoverySource = Literal["plyngent", "claude"]
@@ -150,6 +160,14 @@ class AgentConfig(Struct, omit_defaults=True):
compact_user_prefix: str = ""
compact_seed_text: str = ""
# Thinking strength for every request (a provider or single model may
# override either value; the most specific one wins).
# reasoning_effort: "" | none | minimal | low | medium | high | xhigh | max.
# thinking_budget_tokens: explicit Anthropic ``thinking`` budget (0 = derive
# it from ``reasoning_effort``). See config/reasoning.py.
reasoning_effort: ReasoningEffortConfig = ""
thinking_budget_tokens: int = 0
class PluginsConfig(Struct, omit_defaults=True):
"""Third-party plugins (not tool-specific config).
@@ -268,6 +286,9 @@ class ModelConfig(Struct, omit_defaults=True):
url: str = ""
# DeepSeek API surface override ("" = inherit provider convention).
convention: DeepSeekConvention = ""
# Thinking strength for this model only ("" / 0 = inherit the provider).
reasoning_effort: ReasoningEffortConfig = ""
thinking_budget_tokens: int = 0
class HttpTimeoutConfig(Struct, omit_defaults=True):
@@ -299,6 +320,12 @@ class ProviderConfig(Struct, tag_field="preset", omit_defaults=True):
models: dict[str, ModelConfig] = field(default_factory=dict)
# float = single timeout for the session; table = connect/read split; omit = defaults.
timeout: float | HttpTimeoutConfig | None = None
# Thinking strength for every model of this provider ("" / 0 = inherit [agent]).
reasoning_effort: ReasoningEffortConfig = ""
thinking_budget_tokens: int = 0
# Sources the local ``web_search`` tool tries, in fallback order (``[]``
# disables the tool; see ``config.routing.local_web_search``).
search_sources: list[SearchSource] = field(default_factory=list)
def _default_openai_models() -> dict[str, ModelConfig]:
@@ -310,8 +337,8 @@ def _default_openai_models() -> dict[str, ModelConfig]:
}
def _default_openai_provider_tools() -> list[dict[str, Any]]:
"""Hosted tools when TOML omits ``provider_tools``."""
def _default_provider_tools() -> list[dict[str, Any]]:
"""Hosted tools when TOML omits ``provider_tools`` (OpenAI and DeepSeek)."""
return [{"type": "web_search"}]
@@ -330,7 +357,7 @@ class OpenAIProvider(ProviderConfig, tag="openai"):
"""
models: dict[str, ModelConfig] = field(default_factory=_default_openai_models)
provider_tools: list[dict[str, Any]] = field(default_factory=_default_openai_provider_tools)
provider_tools: list[dict[str, Any]] = field(default_factory=_default_provider_tools)
class OpenAICompatibleProvider(ProviderConfig, tag="openai-compatible"):
@@ -344,11 +371,20 @@ class AnthropicProvider(ProviderConfig, tag="anthropic"):
def _default_deepseek_models() -> dict[str, ModelConfig]:
"""Current DeepSeek text catalog when TOML omits ``models``."""
return {
"deepseek-v4-flash": ModelConfig(text=True),
"deepseek-flash": ModelConfig(text=True),
"deepseek-v4-pro": ModelConfig(text=True),
}
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.
"""
return ["deepseek", "bing"]
class DeepseekProvider(ProviderConfig, tag="deepseek"):
"""DeepSeek API provider.
@@ -358,12 +394,21 @@ class DeepseekProvider(ProviderConfig, tag="deepseek"):
- ``"anthropic"`` → Anthropic Messages API (``POST /messages`` on
``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.
``extras`` keeps arbitrary provider keys; the legacy ``extras.convention``
key is parsed but ignored (prefer the typed ``convention`` field).
"""
models: dict[str, ModelConfig] = field(default_factory=_default_deepseek_models)
convention: DeepSeekConvention = ""
provider_tools: list[dict[str, Any]] = field(default_factory=_default_provider_tools)
search_sources: list[SearchSource] = field(default_factory=_default_deepseek_search_sources)
extras: dict[str, str] = field(default_factory=dict)
+80
View File
@@ -0,0 +1,80 @@
"""Thinking-strength resolution across config layers and API surfaces.
``reasoning_effort`` (and the optional Anthropic ``thinking_budget_tokens``) may
be set on ``[agent]``, on a provider, or on a single model. This module merges
the layers — the most specific one wins, per field — and normalizes the result
for the API surface the provider actually talks to, so the agent loop and the
provider bridges receive one value with a single meaning:
* ``""`` — send nothing; the provider applies its own default.
* chat completions / Responses — ``reasoning_effort`` / ``reasoning.effort``.
* Anthropic — ``thinking`` (an effort level has no wire form there).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from .routing import model_config_for, resolve_effective_provider
if TYPE_CHECKING:
from .models import AgentConfig, Provider, ReasoningEffortConfig
# DeepSeek's own ``reasoning_effort`` accepts "max"; OpenAI and Anthropic do not,
# so anywhere else it clamps down to the highest level they do understand.
_DEEPSEEK_CHAT_CONVENTIONS = frozenset({"", "openai"})
@dataclass(frozen=True, slots=True)
class ReasoningConfig:
"""Merged thinking settings for one provider + model."""
effort: ReasoningEffortConfig = ""
# Explicit Anthropic ``thinking`` budget; 0 = derive it from ``effort``.
thinking_budget_tokens: int = 0
def _clamp_effort(effort: ReasoningEffortConfig, *, preset: str, convention: str) -> ReasoningEffortConfig:
"""Keep ``max`` only on the DeepSeek chat surface that defines it."""
if effort != "max":
return effort
if preset == "deepseek" and convention in _DEEPSEEK_CHAT_CONVENTIONS:
return effort
return "xhigh"
def resolve_reasoning(
agent: AgentConfig | None,
provider: Provider,
model: str | None,
) -> ReasoningConfig:
"""Merge ``[agent]`` < provider < model thinking settings for *model*.
*agent* is ``None`` for callers that only know the provider (config tools,
tests); the model layer is the provider's entry for *model*, if any. Each
field falls back on its own, so a model may inherit only the effort (or only
the budget) from above.
"""
effective = resolve_effective_provider(provider, model=model)
model_config = model_config_for(provider, model)
effort: ReasoningEffortConfig = ""
for layer in (model_config, provider, agent):
if layer is not None and layer.reasoning_effort:
effort = layer.reasoning_effort
break
budget = 0
for layer in (model_config, provider, agent):
if layer is not None and layer.thinking_budget_tokens > 0:
budget = layer.thinking_budget_tokens
break
return ReasoningConfig(
effort=_clamp_effort(effort, preset=effective.preset, convention=effective.convention),
thinking_budget_tokens=budget,
)
__all__ = ["ReasoningConfig", "resolve_reasoning"]
+43 -1
View File
@@ -33,6 +33,8 @@ class EffectiveProvider:
model: str | None
timeout: float | HttpTimeoutConfig | None
provider_tools: list[dict[str, object]]
# Sources the local ``web_search`` tool tries, in fallback order.
search_sources: list[str]
source: Provider
# DeepSeek API surface (see DeepSeekConvention): "openai" / "anthropic" /
# "responses" for deepseek presets; "" (unused) for other presets.
@@ -86,6 +88,39 @@ def model_config_for(provider: Provider, model: str | None) -> ModelConfig | Non
return provider.models.get(model)
def wants_web_search(effective: EffectiveProvider) -> bool:
"""True when *effective*'s ``provider_tools`` ask for a search tool."""
return any(
isinstance(tool.get("type"), str) and tool.get("type") == "web_search" for tool in effective.provider_tools
)
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.
"""
if effective.preset != "deepseek" or effective.convention == "anthropic":
return False
if not effective.search_sources:
return False
return 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 resolve_effective_provider(provider: Provider, *, model: str | None = None) -> EffectiveProvider:
"""Apply model-level preset/url overrides to *provider*.
@@ -116,9 +151,15 @@ def resolve_effective_provider(provider: Provider, *, model: str | None = None)
else:
url = default_url_for_preset(preset)
# Hosted tools and search sources belong to the preset that declares them:
# OpenAI's Responses surface, and DeepSeek's (whose Anthropic surface runs the
# search inside the response while its other surfaces get a local tool — see
# :func:`local_web_search`).
provider_tools: list[dict[str, object]] = []
if preset == "openai" and isinstance(provider, OpenAIProvider) and parent_preset == "openai":
search_sources: list[str] = []
if parent_preset == preset and isinstance(provider, OpenAIProvider | DeepseekProvider):
provider_tools = [dict(item) for item in provider.provider_tools]
search_sources = [str(item) for item in provider.search_sources]
return EffectiveProvider(
preset=preset,
@@ -127,6 +168,7 @@ def resolve_effective_provider(provider: Provider, *, model: str | None = None)
model=model,
timeout=provider.timeout,
provider_tools=provider_tools,
search_sources=search_sources,
source=provider,
convention=convention,
)
+3
View File
@@ -34,6 +34,8 @@ class AnthropicClient:
decoder: msgspec.json.Decoder[AnthropicMessageResponse]
stream_decoder: msgspec.json.Decoder[AnthropicStreamEvent]
models_decoder: msgspec.json.Decoder[AnthropicModelsResponse]
# Explicit ``thinking`` budget for this client (0 = derive from the effort).
thinking_budget_tokens: int = 0
_api_key: str
_api_version: str
@@ -43,6 +45,7 @@ class AnthropicClient:
self.decoder = msgspec.json.Decoder(AnthropicMessageResponse)
self.stream_decoder = msgspec.json.Decoder(AnthropicStreamEvent)
self.models_decoder = msgspec.json.Decoder(AnthropicModelsResponse)
self.thinking_budget_tokens = max(0, config.thinking_budget_tokens)
self._api_key = config.api_key
self._api_version = config.anthropic_version
+3
View File
@@ -20,3 +20,6 @@ class AnthropicConfig:
base_url: str = DEFAULT_ANTHROPIC_BASE_URL
anthropic_version: str = DEFAULT_ANTHROPIC_VERSION
timeout: HttpTimeout = (DEFAULT_HTTP_CONNECT_TIMEOUT, DEFAULT_HTTP_READ_TIMEOUT)
# Explicit ``thinking.budget_tokens`` for reasoning models (0 = derive it
# from the configured reasoning_effort; see config/reasoning.py).
thinking_budget_tokens: int = 0
+62 -5
View File
@@ -2,12 +2,11 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal
from typing import Any, Literal
from msgspec import UNSET, Struct, field
if TYPE_CHECKING:
from plyngent.typedef import Unset
from plyngent.typedef import Unset # noqa: TC001
class AnthropicTextContent(Struct, tag_field="type", tag="text"):
@@ -62,12 +61,30 @@ class AnthropicMetadata(Struct, omit_defaults=True):
user_id: str | Unset = UNSET
class AnthropicThinkingConfig(Struct, omit_defaults=True):
"""``thinking`` request block (extended thinking).
``budget_tokens`` is the reasoning budget the model may spend before it
answers; the API requires it to be at least 1024 and smaller than
``max_tokens`` (see ``agent.messages_bridge``, which keeps both valid).
``type`` carries no default: ``omit_defaults`` would drop a defaulted one,
and the server reads the block as malformed without it — DeepSeek answers
``thinking: missing field ``type``.
"""
type: Literal["enabled", "disabled"]
budget_tokens: int | Unset = UNSET
class AnthropicMessagesParam(Struct, omit_defaults=True):
model: str
max_tokens: int = 8192
messages: list[AnthropicMessage] = field(default_factory=list)
system: str | list[AnthropicTextContent] | Unset = UNSET
tools: list[AnthropicToolDefinition] | Unset = UNSET
# ``dict`` entries are opaque hosted/server tools (e.g. DeepSeek's
# ``{"type": "web_search_20250305", "name": "web_search"}``).
tools: list[AnthropicToolDefinition | dict[str, Any]] | Unset = UNSET
tool_choice: AnthropicToolChoice | Unset = UNSET
metadata: AnthropicMetadata | Unset = UNSET
stop_sequences: list[str] | Unset = UNSET
@@ -75,6 +92,7 @@ class AnthropicMessagesParam(Struct, omit_defaults=True):
temperature: float | Unset = UNSET
top_p: float | Unset = UNSET
top_k: int | Unset = UNSET
thinking: AnthropicThinkingConfig | Unset = UNSET
class AnthropicUsage(Struct, omit_defaults=True):
@@ -94,7 +112,46 @@ class AnthropicResponseToolUse(Struct, tag_field="type", tag="tool_use"):
input: dict[str, Any]
type AnthropicResponseContent = AnthropicResponseText | AnthropicResponseToolUse
class AnthropicThinkingContent(Struct, omit_defaults=True, tag_field="type", tag="thinking"):
"""Extended-thinking block; ``signature`` is opaque and not verified back."""
thinking: str = ""
signature: str | Unset = UNSET
class AnthropicServerToolUseContent(Struct, omit_defaults=True, tag_field="type", tag="server_tool_use"):
"""A tool the *server* runs — a web search the model asked its backend for.
Not a call for the local registry: the matching
:class:`AnthropicWebSearchToolResultContent` arrives in the same response
(see the ``messages_dispatch`` / ``messages_bridge`` bullets in AGENTS.md).
"""
id: str = ""
name: str = ""
input: dict[str, Any] = field(default_factory=dict)
class AnthropicWebSearchToolResultContent(Struct, omit_defaults=True, tag_field="type", tag="web_search_tool_result"):
"""What the server's search returned for one ``server_tool_use`` call.
``content`` stays loose: it holds ``web_search_result`` entries (title, url
and an ``encrypted_content`` the API demands back unchanged — DeepSeek
answers a trimmed result with 422) or a single
``web_search_tool_result_error`` such as ``max_uses_exceeded``.
"""
tool_use_id: str = ""
content: list[dict[str, Any]] = field(default_factory=list)
type AnthropicResponseContent = (
AnthropicResponseText
| AnthropicResponseToolUse
| AnthropicThinkingContent
| AnthropicServerToolUseContent
| AnthropicWebSearchToolResultContent
)
class AnthropicMessageResponse(Struct, omit_defaults=True):
@@ -17,8 +17,8 @@ class DeepseekAnthropicClient(AnthropicClient):
(see https://api-docs.deepseek.com/guides/anthropic_api). ``x-api-key`` is fully
supported; ``anthropic-version`` / ``anthropic-beta`` headers are ignored by the
server but harmless. Model ids map server-side: ``claude-opus*`` → ``deepseek-v4-pro``,
``claude-haiku*`` / ``claude-sonnet*`` → ``deepseek-v4-flash``, unknown names →
``deepseek-v4-flash``; real DeepSeek ids pass through as-is.
``claude-haiku*`` / ``claude-sonnet*`` → ``deepseek-flash``, unknown names →
``deepseek-flash``; real DeepSeek ids pass through as-is.
``GET /models`` is not documented on the ``/anthropic`` base, so ``models()``
returns an empty list and model selection stays config-driven.
@@ -0,0 +1,148 @@
"""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",
]
@@ -1,7 +1,7 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Literal, overload
from typing import TYPE_CHECKING, Literal, cast, overload
import msgspec
@@ -16,16 +16,43 @@ if TYPE_CHECKING:
from .model import ChatCompletionsParam
def _inject_thinking(data: bytes) -> bytes:
"""Inject ``thinking: {type: "enabled"}`` into the encoded request body.
def _inject_thinking(data: bytes, param: ChatCompletionsParam) -> bytes:
"""Inject the ``thinking`` flag into the encoded request body.
DeepSeek's reasoning model requires the ``thinking`` parameter to be
explicitly set (default is ``enabled``). The agent loop constructs the
base ``ChatCompletionsParam`` which lacks this field, so we add it
after msgspec encoding.
explicitly set (default is ``enabled``); ``reasoning_effort = "none"`` is the
way to ask for it off. The agent loop constructs the base
``ChatCompletionsParam`` which lacks this field, so we add it after msgspec
encoding.
"""
body = json.loads(data)
body["thinking"] = {"type": "enabled"}
body["thinking"] = {"type": "disabled" if param.reasoning_effort == "none" else "enabled"}
return json.dumps(body, separators=(",", ":")).encode("utf-8")
def _inject_missing_reasoning_content(data: bytes) -> bytes:
"""Give every assistant message a ``reasoning_content`` field.
Thinking mode rejects an assistant round whose message carries no reasoning
("the ``reasoning_content`` … must be passed back to the API") — for a tool
call and for a plain answer alike. History can hold such a round: a
rolled-back turn keeps the calls that ran as a bare pairing carrier without
it (see ``rollback_tail``), a synthetic ``todo_list`` nag is forged the same
way, and a compacted session starts with a seed summary carrying none. The
field only has to be there, so an empty string satisfies the check (unlike
the Responses API, whose ``reasoning`` item needs non-empty text — see
``agent.responses_bridge``).
"""
body = cast("dict[str, object]", json.loads(data))
messages = body.get("messages")
if isinstance(messages, list):
for message in cast("list[object]", messages):
if not isinstance(message, dict):
continue
item = cast("dict[str, object]", message)
if item.get("role") != "assistant" or "reasoning_content" in item:
continue
item["reasoning_content"] = ""
return json.dumps(body, separators=(",", ":")).encode("utf-8")
@@ -49,7 +76,8 @@ class DeepseekOpenAIClient(BaseOpenAIClient):
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
param = coerce_chat_completions_param_any(msgspec.structs.replace(param, stream=stream))
data = _inject_thinking(self.encoder.encode(param))
data = _inject_thinking(self.encoder.encode(param), param)
data = _inject_missing_reasoning_content(data)
if stream:
resp = await self.session.post(
"/chat/completions",
+12 -12
View File
@@ -232,24 +232,24 @@ class Response(Struct, omit_defaults=True):
output: list[dict[str, Any]]
object: Literal["response"] = "response"
status: ResponseStatus | Unset = UNSET
error: ResponseError | None | Unset = UNSET
incomplete_details: ResponseIncompleteDetails | None | Unset = UNSET
instructions: str | list[dict[str, Any]] | None | Unset = UNSET
metadata: dict[str, str] | None | Unset = UNSET
error: ResponseError | Unset | None = UNSET
incomplete_details: ResponseIncompleteDetails | Unset | None = UNSET
instructions: str | list[dict[str, Any]] | Unset | None = UNSET
metadata: dict[str, str] | Unset | None = UNSET
parallel_tool_calls: bool | Unset = UNSET
temperature: float | None | Unset = UNSET
top_p: float | None | Unset = UNSET
temperature: float | Unset | None = UNSET
top_p: float | Unset | None = UNSET
tools: list[dict[str, Any]] | Unset = UNSET
tool_choice: str | dict[str, Any] | Unset = UNSET
# Prefer loose dict — nested usage details vary by model/API version.
usage: dict[str, Any] | None | Unset = UNSET
previous_response_id: str | None | Unset = UNSET
service_tier: ServiceTier | None | Unset = UNSET
truncation: TruncationMode | None | Unset = UNSET
usage: dict[str, Any] | Unset | None = UNSET
previous_response_id: str | Unset | None = UNSET
service_tier: ServiceTier | Unset | None = UNSET
truncation: TruncationMode | Unset | None = UNSET
text: dict[str, Any] | Unset = UNSET
reasoning: dict[str, Any] | None | Unset = UNSET
reasoning: dict[str, Any] | Unset | None = UNSET
store: bool | Unset = UNSET
user: str | None | Unset = UNSET
user: str | Unset | None = UNSET
class ResponseDeleted(Struct):
@@ -59,7 +59,7 @@ async def read_response_body(resp: object) -> bytes | str | None:
return None
def sse_data_payload(line: bytes) -> bytes | None | Literal[False]:
def sse_data_payload(line: bytes) -> bytes | Literal[False] | None:
"""Parse one SSE line.
Returns:
@@ -67,7 +67,7 @@ type AnyAssistantToolCall = AssistantFunctionToolCall | AssistantCustomToolCall
class AssistantChatMessage(Struct, tag_field="role", tag="assistant"):
# APIs often return content=null when tool_calls are present.
content: str | None | Unset = UNSET
content: str | Unset | None = UNSET
name: str | Unset = UNSET
audio: IDObject | Unset = UNSET
refusal: str | Unset = UNSET
@@ -237,8 +237,8 @@ class StreamToolCallDelta(Struct):
class DeltaMessage(Struct):
role: RoleAssistant | Unset = UNSET
# Providers often send content=null / reasoning_content=null on partial chunks.
content: str | None | Unset = UNSET
reasoning_content: str | None | Unset = UNSET
content: str | Unset | None = UNSET
reasoning_content: str | Unset | None = UNSET
# True when reasoning_content is the FULL chain-of-thought (e.g. DeepSeek's
# ``response.reasoning_text.done``), replacing any streamed fragments.
reasoning_full: bool | Unset = UNSET
@@ -249,8 +249,8 @@ class ChunkChoice(Struct):
index: int
delta: DeltaMessage
# APIs often send logprobs=null on stream choices.
logprobs: dict[str, Any] | None | Unset = UNSET
finish_reason: FinishReason | None | Unset = UNSET
logprobs: dict[str, Any] | Unset | None = UNSET
finish_reason: FinishReason | Unset | None = UNSET
class ChatCompletionChunk(Struct):
@@ -260,7 +260,7 @@ class ChatCompletionChunk(Struct):
model: str
choices: list[ChunkChoice]
# Final usage chunk may omit or null usage depending on provider.
usage: dict[str, Any] | None | Unset = UNSET
usage: dict[str, Any] | Unset | None = UNSET
class ModelObject(Struct):
+29 -2
View File
@@ -93,6 +93,30 @@ class PromptBackend(Protocol):
def secho(self, message: str, *, fg: str | None = None, err: bool = False) -> None: ...
# Hook run right before a backend writes prompt chrome (a confirm box, a question
# label). Hosts install one so an open pretty tool line — see
# ``plyngent.cli.display`` — is ended before the prompt takes that line over.
_before_output: Callable[[], None] | None = None
def set_prompt_output_hook(hook: Callable[[], None] | None) -> None:
"""Install the hook run before prompt output (None clears it)."""
global _before_output # noqa: PLW0603
_before_output = hook
def get_prompt_output_hook() -> Callable[[], None] | None:
"""The installed prompt-output hook, if any."""
return _before_output
def _break_output() -> None:
"""Run the prompt-output hook, if one is installed."""
hook = _before_output
if hook is not None:
hook()
def _readline_input(prompt: str, *, completions: Sequence[str] | None = None) -> str:
"""``input()`` with optional Tab completion via readline when available."""
try:
@@ -255,9 +279,11 @@ class ClickPromptBackend:
raise NonInteractiveError(msg) from exc
def echo(self, message: str = "", *, err: bool = False) -> None:
_break_output()
click.echo(message, err=err)
def secho(self, message: str, *, fg: str | None = None, err: bool = False) -> None:
_break_output()
click.secho(message, fg=fg, err=err)
@@ -319,10 +345,11 @@ def get_prompt_backend() -> PromptBackend:
def reset_prompting() -> None:
"""Restore default Click backend, clear the pause hook and cancel flag (tests)."""
global _backend, _pause_factory # noqa: PLW0603
"""Restore the default backend; clear the pause/output hooks and cancel flag (tests)."""
global _backend, _pause_factory, _before_output # noqa: PLW0603
_backend = ClickPromptBackend()
_pause_factory = None
_before_output = None
_prompt_cancel.clear()
+19 -4
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import math
from typing import TYPE_CHECKING
from plyngent.config.reasoning import ReasoningConfig, resolve_reasoning
from plyngent.config.routing import EffectiveProvider, resolve_effective_provider
from plyngent.lmproto.anthropic import AnthropicClient
from plyngent.lmproto.anthropic.config import AnthropicConfig as AnthropicConfigCls
@@ -103,7 +104,7 @@ def provider_to_openai_config(
)
def _anthropic_client(effective: EffectiveProvider) -> AnthropicClient:
def _anthropic_client(effective: EffectiveProvider, reasoning: ReasoningConfig) -> AnthropicClient:
"""Build an Anthropic Messages client (real or DeepSeek convention) for *effective*."""
try:
http_timeout = normalize_http_timeout(effective.timeout)
@@ -115,6 +116,7 @@ def _anthropic_client(effective: EffectiveProvider) -> AnthropicClient:
api_key=effective.access_key_or_token,
base_url=effective.url or DEFAULT_DEEPSEEK_BASE_URL,
timeout=http_timeout,
thinking_budget_tokens=reasoning.thinking_budget_tokens,
)
)
return AnthropicClient(
@@ -122,11 +124,17 @@ def _anthropic_client(effective: EffectiveProvider) -> AnthropicClient:
api_key=effective.access_key_or_token,
base_url=effective.url or DEFAULT_ANTHROPIC_BASE_URL,
timeout=http_timeout,
thinking_budget_tokens=reasoning.thinking_budget_tokens,
)
)
def create_client(provider: Provider, *, model: str | None = None) -> ProtocolClient:
def create_client(
provider: Provider,
*,
model: str | None = None,
reasoning: ReasoningConfig | None = None,
) -> ProtocolClient:
"""Build a protocol client for *provider*, applying model-level routing.
``preset`` always decides API conventions: ``openai`` → /responses,
@@ -134,8 +142,15 @@ def create_client(provider: Provider, *, model: str | None = None) -> ProtocolCl
Exception: a ``deepseek`` preset picks the Responses or Anthropic surface
when its effective ``convention`` is ``"responses"`` / ``"anthropic"``,
else chat completions.
*reasoning* is the merged thinking config for this provider + model (see
:func:`plyngent.config.reasoning.resolve_reasoning`); callers without an
``[agent]`` layer may omit it and get the provider/model layers only. It only
reaches surfaces that cannot read the setting off the request param —
Anthropic needs a token budget, while chat/Responses carry an effort level.
"""
effective = resolve_effective_provider(provider, model=model)
spec = reasoning if reasoning is not None else resolve_reasoning(None, provider, model)
if effective.preset == "openai":
return OpenAIClient(provider_to_openai_config(effective))
if effective.preset == "openai-compatible":
@@ -144,9 +159,9 @@ def create_client(provider: Provider, *, model: str | None = None) -> ProtocolCl
if effective.convention == "responses":
return DeepseekResponsesClient(provider_to_openai_config(effective))
if effective.convention == "anthropic":
return _anthropic_client(effective)
return _anthropic_client(effective, spec)
return DeepseekOpenAIClient(provider_to_openai_config(effective))
if effective.preset == "anthropic":
return _anthropic_client(effective)
return _anthropic_client(effective, spec)
msg = f"provider preset {effective.preset!r} is not supported"
raise ProviderNotSupportedError(msg)
+2
View File
@@ -88,6 +88,7 @@ from .workspace import (
)
from .workspace import DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS as DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS
from .workspace import AccessMode as AccessMode
from .workspace import DenylistHit as DenylistHit
from .workspace import WorkspaceError as WorkspaceError
from .workspace import WorkspacePolicy as WorkspacePolicy
from .workspace import active_workspace_policy as active_workspace_policy
@@ -104,6 +105,7 @@ from .workspace import get_workspace_root as get_workspace_root
from .workspace import grant_policy_command as grant_policy_command
from .workspace import granted_access_mode as granted_access_mode
from .workspace import list_workspace_allowlist as list_workspace_allowlist
from .workspace import match_command_denylist as match_command_denylist
from .workspace import mode_covers as mode_covers
from .workspace import parse_access_mode as parse_access_mode
from .workspace import pop_owned_temporary_workspaces as pop_owned_temporary_workspaces
+359
View File
@@ -0,0 +1,359 @@
"""Resolve the program a command really runs, past wrappers and launchers.
Confirming a risky command (``danger``) and denying a policy-blocked one
(``workspace.check_command_allowed``) must judge the program that will really
run, not the outermost token: ``env FOO=1 python -c …``, ``nohup pdm run python
…`` and ``timeout 5 bash -c …`` all reach the interpreter — or the denylisted
program — further in.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
__all__ = ["CommandString", "UnwrappedCommand", "basename", "is_interpreter", "unwrap_command"]
# Wrappers that run the program named on their own command line.
_ENV_WRAPPERS: frozenset[str] = frozenset(
{
"command",
"doas",
"env",
"exec",
"ionice",
"nice",
"nohup",
"pkexec",
"setsid",
"stdbuf",
"su",
"sudo",
"time",
"timeout",
"watch",
"xargs",
}
)
# Detached runners: review-worthy whatever program they wrap.
_SELF_REVIEW: frozenset[str] = frozenset({"nohup"})
# Wrappers that take a bare duration of their own (``timeout 5 cmd``).
_DURATION_WRAPPERS: frozenset[str] = frozenset({"timeout"})
# ``<launcher> run <program> …``: task runners and package managers that launch
# a program in their environment. A package-manager script (``npm run build``)
# names a script rather than a program; the launcher basename is still checked.
_RUN_LAUNCHERS: frozenset[str] = frozenset(
{"conda", "hatch", "mise", "npm", "pdm", "pipenv", "pnpm", "poetry", "uv", "yarn"}
)
# Programs that run code handed to them as text: for these, ``-c`` carries the
# code (``grep -c`` / ``od -c`` is a plain flag, never a block of code).
_INTERPRETERS: frozenset[str] = frozenset(
{
"bash",
"bun",
"cmd",
"csh",
"dash",
"deno",
"fish",
"ghci",
"ipython",
"ipython3",
"irb",
"jshell",
"julia",
"ksh",
"lua",
"mongo",
"mysql",
"node",
"nodejs",
"perl",
"php",
"powershell",
"pry",
"psql",
"pwsh",
"python",
"python2",
"python3",
"r",
"redis-cli",
"ruby",
"scala",
"sh",
"sqlite3",
"tcsh",
"zsh",
}
)
# Trailing version of an interpreter: ``python3.12`` / ``node20`` / ``php8.2``.
_VERSION_SUFFIX = re.compile(r"[0-9][0-9.]*$")
# ``FOO=bar`` in front of a program (env/sudo style).
_ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=")
# A bare duration, as ``timeout`` takes: ``5``, ``1.5``, ``30s``.
_DURATION = re.compile(r"[0-9]+(?:\.[0-9]+)?[smhd]?")
# Options that consume the next token, so ``sudo -u root python`` still finds
# ``python``; jammed forms (``-n10``, ``--with=pkg``) need no entry, and unknown
# options are skipped bare. List value-taking options only: an entry for a flag
# eats the program behind it, which hid ``rm`` from ``exec -c rm -rf /`` and
# judged ``exec -c git -c … init`` as ``init``. An option with an *optional*
# value (``xargs -e``, ``watch -d``) needs no entry either — its bare form runs
# the program, and the jammed form carries the value in the same token.
_VALUE_OPTS: dict[str, frozenset[str]] = {
"doas": frozenset({"-u", "--user"}),
"env": frozenset({"-C", "-S", "-u", "--chdir", "--split-string", "--unset"}),
"exec": frozenset({"-a"}),
"ionice": frozenset({"-c", "-n", "-p", "-P", "-u"}),
"nice": frozenset({"-n", "--adjustment"}),
"pkexec": frozenset({"--user"}),
"stdbuf": frozenset({"-e", "-i", "-o"}),
"su": frozenset({"-c", "-g", "-G", "-s", "-u", "-w", "--command", "--group", "--session-command", "--shell"}),
"sudo": frozenset(
{
"-C",
"-D",
"-g",
"-h",
"-p",
"-r",
"-R",
"-t",
"-T",
"-u",
"-U",
"--close-from",
"--command-timeout",
"--group",
"--host",
"--other-user",
"--prompt",
"--role",
"--type",
"--user",
}
),
"time": frozenset({"-f", "-o", "--format", "--output"}),
"timeout": frozenset({"-k", "-s", "--kill-after", "--signal"}),
"watch": frozenset({"-n"}),
"xargs": frozenset({"-a", "-d", "-E", "-I", "-L", "-n", "-P", "-s"}),
}
# Options whose value is a *command line* the wrapper will run, so the value is
# code rather than an argument: ``env -S "rm -rf /"`` and ``su -c "…"``. An
# interpreter's own ``-c`` is the same idea, but there the interpreter is the
# program under review; here a wrapper hides the command, so the review has to
# surface it (:func:`plyngent.tools.danger.classify_danger`).
_COMMAND_STRING_OPTS: dict[str, frozenset[str]] = {
"env": frozenset({"-S", "--split-string"}),
"su": frozenset({"-c", "--command", "--session-command"}),
}
# Launcher options that consume a value (``uv run --with rich python``).
_RUN_VALUE_OPTS: frozenset[str] = frozenset(
{
"-C",
"-e",
"-F",
"-G",
"-m",
"-n",
"-p",
"-s",
"-w",
"--config",
"--constraint",
"--default-index",
"--directory",
"--env",
"--env-file",
"--extra",
"--extras",
"--file",
"--group",
"--index",
"--module",
"--name",
"--overrides",
"--package",
"--prefix",
"--project",
"--python",
"--python-version",
"--script",
"--with",
"--without",
}
)
@dataclass(frozen=True)
class CommandString:
"""A command line a wrapper took as an option *value* (``env -S …``)."""
wrapper: str
option: str
text: str
@property
def label(self) -> str:
"""``env -S`` — how the option is named in review output."""
return f"{self.wrapper} {self.option}"
@dataclass(frozen=True)
class UnwrappedCommand:
"""A command plus the program it really runs, past wrappers and launchers."""
argv: tuple[str, ...]
offset: int
wrappers: tuple[str, ...]
base: str
self_review: tuple[str, ...]
command_strings: tuple[CommandString, ...] = ()
@property
def command(self) -> tuple[str, ...]:
"""The program and its arguments (the whole argv when nothing unwrapped)."""
return self.argv[self.offset :]
def basename(argv0: str) -> str:
"""Lower-cased basename of an executable token (``\\`` separators, ``.exe``)."""
name = argv0.replace("\\", "/").rsplit("/", 1)[-1].lower()
return name.removesuffix(".exe")
def is_interpreter(base: str) -> bool:
"""Whether *base* runs code from its own command line.
Version suffixes are ignored, so ``python3.12`` / ``node20`` / ``php8.2``
count as interpreters too.
"""
return base in _INTERPRETERS or _VERSION_SUFFIX.sub("", base) in _INTERPRETERS
def unwrap_command(argv: Sequence[str]) -> UnwrappedCommand:
"""Resolve *argv* to the program behind any wrappers and ``run`` launchers.
Wrappers are unwrapped outer → inner as long as a program still follows
them; their own options and ``FOO=bar`` assignments are skipped, and an
option that carries a command line is collected in ``command_strings``.
"""
parts = list(argv)
wrappers: list[str] = []
command_strings: list[CommandString] = []
index = 0
while index < len(parts):
name = basename(parts[index])
nxt = _skip_wrapper(name, parts, index + 1, command_strings=command_strings)
if nxt is None:
break
wrappers.append(name)
index = nxt
base = basename(parts[index]) if index < len(parts) else ""
chain = dict.fromkeys((*wrappers, base))
return UnwrappedCommand(
argv=tuple(parts),
offset=index,
wrappers=tuple(wrappers),
base=base,
self_review=tuple(name for name in chain if name in _SELF_REVIEW),
command_strings=tuple(command_strings),
)
def _skip_wrapper(
name: str,
parts: list[str],
start: int,
*,
command_strings: list[CommandString],
) -> int | None:
"""Index of the program *name* wraps, or ``None`` when it is the whole command."""
if name in _ENV_WRAPPERS:
nxt = _skip_options(
parts,
start,
name=name,
value_opts=_VALUE_OPTS.get(name, frozenset()),
leading_duration=name in _DURATION_WRAPPERS,
command_strings=command_strings,
)
return nxt if nxt < len(parts) else None
if name in _RUN_LAUNCHERS and start < len(parts) and parts[start] == "run":
nxt = _skip_options(
parts,
start + 1,
name=name,
value_opts=_RUN_VALUE_OPTS,
leading_duration=False,
command_strings=command_strings,
)
return nxt if nxt < len(parts) else None
return None
def _record_command_string(
name: str,
option: str,
text: str,
command_strings: list[CommandString],
) -> None:
"""Note *text* when *option* hands a command line to wrapper *name*."""
if option in _COMMAND_STRING_OPTS.get(name, frozenset()):
command_strings.append(CommandString(wrapper=name, option=option, text=text))
def _skip_options(
parts: list[str],
start: int,
*,
name: str,
value_opts: frozenset[str],
leading_duration: bool,
command_strings: list[CommandString],
) -> int:
"""Index of the first token after a wrapper's own options and assignments."""
index = start
took_duration = False
while index < len(parts):
token = parts[index]
if token == "--":
return index + 1
if _ASSIGNMENT.match(token):
index += 1
continue
if token.startswith("-"):
index += 1
if token in value_opts:
value = parts[index] if index < len(parts) else ""
_record_command_string(name, token, value, command_strings)
index += 1
continue
# Jammed forms: ``--split-string=…`` and ``-S…`` carry the value in
# the token itself, so a command line may hide there too.
option, equal, attached = token.partition("=")
if equal:
_record_command_string(name, option, attached, command_strings)
elif token[:2] in _COMMAND_STRING_OPTS.get(name, frozenset()):
_record_command_string(name, token[:2], token[2:], command_strings)
continue
if leading_duration and not took_duration and _DURATION.fullmatch(token):
took_duration = True
index += 1
continue
return index
return index
+48 -95
View File
@@ -4,61 +4,13 @@ import json
import shlex
from typing import TYPE_CHECKING, cast
from plyngent.tools.command_scan import is_interpreter, unwrap_command
from plyngent.tools.workspace import AccessMode, WorkspaceError, resolve_path
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
_SHELL_BASENAMES: frozenset[str] = frozenset(
{
"bash",
"sh",
"zsh",
"fish",
"dash",
"ksh",
"csh",
"tcsh",
"powershell",
"pwsh",
"cmd",
"cmd.exe",
"python",
"python3",
"python2",
"ipython",
"ipython3",
"node",
"nodejs",
"deno",
"bun",
"ruby",
"perl",
"php",
"lua",
"r",
"julia",
"irb",
"pry",
"ghci",
"scala",
"jshell",
"sqlite3",
"psql",
"mysql",
"mongo",
"redis-cli",
}
)
def _basename(argv0: str) -> str:
name = argv0.replace("\\", "/").rsplit("/", 1)[-1]
name = name.removesuffix(".exe")
return name.lower()
def _as_argv(args: Mapping[str, object], *, key: str = "command") -> list[str] | None:
command = args.get(key)
if not isinstance(command, list) or not command:
@@ -71,67 +23,67 @@ def _as_argv(args: Mapping[str, object], *, key: str = "command") -> list[str] |
return out
def _find_dash_c_code(argv: Sequence[str]) -> str | None:
"""Return the argument after ``-c`` / ``-c…`` if present (python/bash/node style)."""
for index, part in enumerate(argv[1:], start=1):
def _dash_c_code(command: Sequence[str]) -> tuple[int, str] | None:
"""Index and text of the ``-c`` code in an interpreter argv, if present.
Only interpreters hand ``-c`` a block of code; for anything else it is a
plain flag (``grep -c``, ``od -c``) with nothing to review.
"""
for index, part in enumerate(command[1:], start=1):
if part == "-c":
return argv[index + 1] if index + 1 < len(argv) else ""
return index, command[index + 1] if index + 1 < len(command) else ""
# Combined short forms are uncommon; only exact -c is supported.
return None
def _indent_block(text: str, *, prefix: str = " ") -> str:
"""Indent every line of *text* (for multi-line -c bodies in confirm prompts)."""
"""Indent every line of *text* (for multi-line -c code in confirm prompts)."""
if not text:
return prefix
return "\n".join(prefix + line if line else prefix.rstrip() for line in text.splitlines())
def _format_argv_for_confirm(argv: Sequence[str], *, code: str | None) -> str:
"""One-line argv summary; replace -c payload with ``$(command)`` when present."""
if code is None:
return shlex.join(list(argv))
parts: list[str] = []
skip_next = False
for part in argv:
if skip_next:
skip_next = False
continue
if part == "-c":
parts.append("-c")
parts.append("$(command)")
skip_next = True
continue
parts.append(part)
def _format_argv_for_confirm(argv: Sequence[str], *, code_index: int | None) -> str:
"""One-line argv summary; the ``-c`` code token becomes ``$(command)``."""
parts = list(argv)
if code_index is not None and code_index + 1 < len(parts):
parts[code_index + 1] = "$(command)"
return shlex.join(parts)
def _shell_or_dash_c_reason(argv: Sequence[str], *, via: str) -> str | None:
"""Confirm interactive shells and ``*-c`` one-liners so the user can inspect argv.
def _command_reason(argv: Sequence[str], *, via: str) -> str | None:
"""Confirm a command that runs code or detaches, whatever wraps it.
Multi-line reason (shown inside the CLI confirm box). ``via`` is a short
label such as ``run_argv`` or ``open_pty``.
For ``-c`` scripts, argv shows ``$(command)`` instead of inlining the body;
the full script is printed below untruncated, with every line indented.
The program is resolved past environment wrappers and ``<launcher> run``
forms (``env FOO=1 python …``, ``nohup pdm run python …``), and an
interpreter is confirmed the same way however it runs — a bare shell, a
script (``python x.py``), or a ``-c`` one-liner, whose code is printed below
``command:`` instead of inline. A wrapper option that takes a command line
(``env -S "…"``, ``su -c "…"``) hides code the same way, so it is confirmed
too, with the text it carries printed under its own ``<wrapper> <option>:``
label.
"""
if not argv:
scan = unwrap_command(argv)
interpreter = is_interpreter(scan.base)
findings = [f"{name} (detached run)" for name in scan.self_review]
if interpreter:
findings.append(f"interpreter {scan.base!r}")
findings.extend(f"{span.label} runs a command line" for span in scan.command_strings)
findings = list(dict.fromkeys(findings))
if not findings:
return None
base = _basename(argv[0])
code = _find_dash_c_code(argv)
display = _format_argv_for_confirm(argv, code=code)
code = _dash_c_code(scan.command) if interpreter else None
code_index = scan.offset + code[0] if code is not None else None
display = _format_argv_for_confirm(argv, code_index=code_index)
reason = f"{via}: {' + '.join(findings)} — review before allow\n argv: {display}"
if code is not None:
body = _indent_block(code, prefix=" ")
return f"{via}: {base} -c (review code before allow)\n argv: {display}\n command:\n{body}"
if base in _SHELL_BASENAMES and len(argv) == 1:
return f"{via}: interactive {base!r} (review before allow)\n argv: {display}"
if base in _SHELL_BASENAMES and "-c" not in argv[1:]:
return f"{via}: shell/runtime {base!r} without -c (review before allow)\n argv: {display}"
return None
reason += f"\n command:\n{_indent_block(code[1])}"
for span in scan.command_strings:
reason += f"\n {span.label}:\n{_indent_block(span.text)}"
return reason
def _write_file_reason(args: Mapping[str, object]) -> str | None:
@@ -185,7 +137,7 @@ def _run_argv_reason(args: Mapping[str, object]) -> str | None:
argv = _as_argv(args, key="argv")
if argv is None:
return None
return _shell_or_dash_c_reason(argv, via="run_argv")
return _command_reason(argv, via="run_argv")
def _batch_step_argv(item: object) -> list[str] | None:
@@ -204,7 +156,7 @@ def _batch_step_argv(item: object) -> list[str] | None:
def _run_argv_batch_reason(args: Mapping[str, object]) -> str | None:
"""One confirm for the whole batch if any step is shell/REPL/-c."""
"""One confirm for the whole batch if any step runs code or detaches."""
raw = args.get("steps")
if isinstance(raw, str):
try:
@@ -218,7 +170,7 @@ def _run_argv_batch_reason(args: Mapping[str, object]) -> str | None:
reason
for index, item in enumerate(cast("list[object]", raw))
if (argv := _batch_step_argv(item)) is not None
and (reason := _shell_or_dash_c_reason(argv, via=f"run_argv_batch[{index}]")) is not None
and (reason := _command_reason(argv, via=f"run_argv_batch[{index}]")) is not None
]
if not risky:
return None
@@ -230,7 +182,7 @@ def _open_pty_reason(args: Mapping[str, object]) -> str | None:
argv = _as_argv(args)
if argv is None:
return None
return _shell_or_dash_c_reason(argv, via="open_pty")
return _command_reason(argv, via="open_pty")
def _fetch_reason(args: Mapping[str, object]) -> str | None:
@@ -250,8 +202,9 @@ def classify_danger(name: str, args: Mapping[str, object]) -> str | None: # noq
"""Return a short reason if ``name``/``args`` need user confirm, else ``None``.
Hard denylists (paths/commands) still raise independently. This only covers
soft confirms for mutating tools and risky shell/REPL launches
(interactive shells and ``python -c`` / ``bash -c`` one-liners).
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).
"""
if name == "delete_path":
+4 -6
View File
@@ -7,7 +7,6 @@ from typing import cast
from plyngent.agent import ToolTag, tool
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
from plyngent.tools.file.read import line_range_label, read_file, read_raw_text
from plyngent.tools.net.fetch import fetch
from plyngent.tools.truncate_token import (
TruncateToken,
decode_truncate_token,
@@ -61,19 +60,18 @@ 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_command``, or earlier ``get_truncated`` result to keep reading without
``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) are served
from a short-lived in-memory store that is forgotten when the agent exits.
``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.
"""
parsed = decode_truncate_token(token)
if parsed is None:
return "error: invalid truncate token"
if parsed.kind == "http":
return await fetch.handler(parsed.location, offset=parsed.offset, max_chars=parsed.limit)
if parsed.kind == "memory":
return _memory_chunk(parsed, max_chars)
text, err = read_raw_text(parsed.location)
+66
View File
@@ -0,0 +1,66 @@
"""Bing's RSS endpoint as a search source for the local ``web_search`` tool.
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
private-host grant policy applies to it as well.
"""
from __future__ import annotations
import urllib.parse
import xml.etree.ElementTree as ET
from plyngent.lmproto.deepseek.anthropic.search import SearchHit
from plyngent.tools.net.client import http_fetch
SEARCH_URL = "https://www.bing.com/search"
DEFAULT_TIMEOUT_SECONDS = 30.0
DEFAULT_MAX_BYTES = 2_000_000
_HTTP_ERROR = 400
def parse_bing_rss(document: str) -> list[SearchHit]:
"""Map Bing's RSS items to hits (title, link, description as the snippet)."""
try:
root = ET.fromstring(document)
except ET.ParseError as exc:
msg = f"bing search returned unparseable XML: {exc}"
raise RuntimeError(msg) from exc
hits: list[SearchHit] = []
for item in root.iter("item"):
title = (item.findtext("title") or "").strip()
url = (item.findtext("link") or "").strip()
if not url:
continue
hits.append(SearchHit(title=title, url=url, snippet=(item.findtext("description") or "").strip()))
return hits
class BingSearchBackend:
"""Search through Bing's RSS results (no key, snippets included)."""
timeout_seconds: float
def __init__(self, *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS) -> None:
self.timeout_seconds = timeout_seconds
async def search(self, query: str) -> list[SearchHit]:
"""Run *query* against Bing and return the items it answered with."""
query_string = urllib.parse.urlencode({"format": "rss", "q": query})
result = await http_fetch(
method="GET",
url=f"{SEARCH_URL}?{query_string}",
headers={},
body=None,
timeout_seconds=self.timeout_seconds,
max_bytes=DEFAULT_MAX_BYTES,
)
if result.status >= _HTTP_ERROR:
msg = f"bing search HTTP {result.status}"
raise RuntimeError(msg)
return parse_bing_rss(result.body_text)
__all__ = ["SEARCH_URL", "BingSearchBackend", "parse_bing_rss"]
+5 -11
View File
@@ -17,7 +17,7 @@ from plyngent.tools.net.policy import (
parse_fetch_url,
soft_confirm_reason,
)
from plyngent.tools.truncate_token import truncate_with_token
from plyngent.tools.truncate_token import truncate_to_memory
def format_fetch_result(
@@ -37,16 +37,10 @@ def format_fetch_result(
offset: int = 0,
) -> str:
start = max(0, offset)
body = body_text[start:]
body, token = truncate_with_token(
body,
max_chars,
kind="http",
location=final_url,
offset=start,
limit=max_chars,
total_len=len(body_text),
)
# The body is already in hand, so the remainder is kept in memory rather than
# re-requested: resuming a fetch must not cost another round trip (latency)
# nor re-hit the remote (rate limits / risk control).
body, token = truncate_to_memory(body_text[start:], max_chars)
char_truncated = token is not None
warn_line = "; ".join(warnings) if warnings else ""
parts = [
+116
View File
@@ -0,0 +1,116 @@
"""``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.
"""
from __future__ import annotations
import inspect
from typing import TYPE_CHECKING, Protocol
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,
)
WEB_SEARCH_TOOL_NAME = "web_search"
DEFAULT_MAX_HITS = 8
class SearchBackend(Protocol):
"""One way to answer a query (``provider.search_sources`` picks the order)."""
async def search(self, query: str) -> Sequence[SearchHit]: ...
class DeepseekSearchBackend:
"""DeepSeek's own index: one server-side search turn per query."""
_client: DeepseekSearchClient
_model: str
def __init__(self, client: DeepseekSearchClient, *, model: str) -> None:
self._client = client
self._model = model
async def search(self, query: str) -> Sequence[SearchHit]:
return await self._client.search(query, model=self._model)
async def search_in_order(
backends: Sequence[SearchBackend],
query: str,
) -> tuple[list[SearchHit], list[str]]:
"""Ask each backend in turn; the first one that answers wins.
Returns ``(hits, failures)`` — a source that raises or finds nothing is
recorded and the next one gets its chance, so a broken or rate-limited
source never takes the tool down with it.
"""
failures: list[str] = []
for backend in backends:
name = type(backend).__name__
try:
hits = list(await backend.search(query))
except Exception as exc: # noqa: BLE001 — a failing source falls through
failures.append(f"{name}: {exc}")
continue
if hits:
return hits, failures
failures.append(f"{name}: no results")
return [], failures
def build_web_search_tool(
backends: Sequence[SearchBackend],
*,
max_hits: int = DEFAULT_MAX_HITS,
) -> ToolDefinition:
"""A ``web_search`` definition over *backends*, tried in order."""
async def web_search(query: str, hits: int = max_hits) -> str:
"""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.
"""
found, failures = await search_in_order(backends, query)
if not found:
detail = "; ".join(failures) if failures else "no source configured"
return f"error: the search found nothing ({detail})"
lines: list[str] = []
for hit in found[:hits]:
lines.append(f"- {hit.title or '(untitled)'} — {hit.url}")
if hit.snippet:
lines.append(f" {hit.snippet}")
return "\n".join(lines)
return ToolDefinition(
name=WEB_SEARCH_TOOL_NAME,
description=inspect.getdoc(web_search) or "",
parameters=schema_from_callable(web_search),
handler=web_search,
tags=ToolTag.LOCAL | ToolTag.READ_ONLY,
)
__all__ = [
"DEFAULT_MAX_HITS",
"WEB_SEARCH_TOOL_NAME",
"DeepseekSearchBackend",
"SearchBackend",
"build_web_search_tool",
"search_in_order",
]
+32 -20
View File
@@ -1,7 +1,7 @@
"""Truncate-token cursors for resuming truncated tool results.
Every truncation site (the agent loop's generic tool-result cap,
``run_command`` / ``run_command_batch`` per-stream caps, ``read_file``,
``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::
@@ -12,11 +12,13 @@ re-requesting the whole source or raising limits. Tokens chain: each truncated
chunk carries a fresh token, so ``get_truncated`` keeps resuming until the
source is exhausted (then no marker is emitted).
Resumable sources (``file`` / ``http``) are re-read by ``location`` +
``offset``. Arbitrary tool output (run_command stdout, todo renders, …) has no
resumable source, 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.
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
forgotten when the agent process exits; the model must re-run the tool after
that.
"""
from __future__ import annotations
@@ -27,18 +29,18 @@ from typing import Literal
import msgspec
type TruncateKind = Literal["file", "http", "memory"]
type TruncateKind = Literal["file", "memory"]
class TruncateToken(msgspec.Struct, frozen=True):
"""Opaque cursor into a truncated source.
``offset``/``limit`` are in characters (http bodies, memory remainders) or
lines (files is line-based via ``read_file``); ``get_truncated`` interprets
by ``kind``. For ``memory``, ``location`` is a key into the in-memory
remainder store and ``offset``/``limit`` index characters of that stored
remainder. ``numbered`` marks file tokens from ``read_file(with_lineno)``:
the continuation resumes the numbered view instead of raw text.
``offset``/``limit`` are in characters (memory remainders) or lines (files
is line-based via ``read_file``); ``get_truncated`` interprets by ``kind``.
For ``memory``, ``location`` is a key into the in-memory remainder store and
``offset``/``limit`` index characters of that stored remainder. ``numbered``
marks file tokens from ``read_file(with_lineno)``: the continuation resumes
the numbered view instead of raw text.
"""
kind: TruncateKind
@@ -134,17 +136,17 @@ def truncate_with_token(
return text + truncation_marker(max_chars, omitted, token), token
def truncate_generic(text: str, max_chars: int) -> str:
"""Cap arbitrary tool output; embed a memory truncate token (chainable).
def truncate_to_memory(text: str, max_chars: int) -> tuple[str, TruncateToken | None]:
"""Cap ``text`` (a whole source) with a memory token over a stored copy.
The full remainder is kept in the in-memory store so ``get_truncated`` can
resume it. Returns ``text`` unchanged (no marker) when it fits within
``max_chars``.
Nothing is stored when the text fits, so a caller that only needs the
bounded text does not pay for a remainder. Returns ``(bounded_text, token)``
where ``token`` is ``None`` when the source is exhausted.
"""
if max_chars < 1 or len(text) <= max_chars:
return text
return text, None
key = store_remainder(text)
bounded, _ = truncate_with_token(
return truncate_with_token(
text,
max_chars,
kind="memory",
@@ -153,4 +155,14 @@ def truncate_generic(text: str, max_chars: int) -> str:
limit=max_chars,
total_len=len(text),
)
def truncate_generic(text: str, max_chars: int) -> str:
"""Cap arbitrary tool output; embed a memory truncate token (chainable).
The full remainder is kept in the in-memory store so ``get_truncated`` can
resume it. Returns ``text`` unchanged (no marker) when it fits within
``max_chars``.
"""
bounded, _ = truncate_to_memory(text, max_chars)
return bounded
+114 -20
View File
@@ -5,6 +5,8 @@ from enum import IntEnum
from pathlib import Path
from typing import TYPE_CHECKING, cast
from plyngent.tools.command_scan import basename, unwrap_command
if TYPE_CHECKING:
from collections.abc import Callable, Mapping, Sequence
@@ -16,6 +18,10 @@ DEFAULT_COMMAND_DENYLIST: frozenset[str] = frozenset(
"su",
"doas",
"pkexec",
# Privileged systemd entry points: run0 is sudo's replacement,
# systemd-run executes a command as a transient system unit.
"run0",
"systemd-run",
"rm",
"rmdir",
"mkfs",
@@ -31,6 +37,18 @@ DEFAULT_COMMAND_DENYLIST: frozenset[str] = frozenset(
"chown",
"mount",
"umount",
"init",
"telinit",
"kexec",
"pm-suspend",
"pm-hibernate",
"s2disk",
"s2ram",
# Machine power state: only these systemctl/loginctl words are blocked,
# so read-only calls (``systemctl status``) stay allowed.
"systemctl reboot poweroff halt suspend hibernate hybrid-sleep "
"suspend-then-hibernate soft-reboot kexec isolate rescue emergency default",
"loginctl terminate-session terminate-user kill-session kill-user reboot poweroff soft-reboot",
}
)
@@ -403,12 +421,88 @@ def argv_shape_error(argv: object) -> str | None:
return None
def check_command_allowed(argv: list[str]) -> None:
"""Raise if argv is empty or the executable basename is denylisted.
@dataclass(frozen=True)
class DenylistHit:
"""A command denylist entry that a command matches.
Denylisted basenames are not hard-rejected when a policy confirm hook is
``program`` names the program to prompt for (and grant by); ``keyword`` is
the argument word that made a subcommand entry dangerous, or ``None`` for an
entry that blocks the whole program.
"""
entry: str
program: str
keyword: str | None = None
def match_command_denylist(argv: Sequence[str], denylist: frozenset[str]) -> list[DenylistHit]:
"""Return the denylist entries *argv* matches, outermost program first.
An entry is a program basename, optionally followed by the argument words
that make it dangerous (``systemctl reboot poweroff …``). A whole-program
entry matches any program of the wrapper chain; a subcommand entry matches
only the program that runs, whose arguments are searched for one of its
words — so ``systemctl reboot`` and ``systemctl start reboot.target`` are
blocked while ``systemctl status`` is not.
"""
scan = unwrap_command(argv)
words = _argument_words(scan.command)
hits: list[DenylistHit] = []
seen: set[str] = set()
for name in (*scan.wrappers, scan.base):
hit = _denylist_hit(name, base=scan.base, words=words, denylist=denylist)
if hit is not None and hit.program not in seen:
seen.add(hit.program)
hits.append(hit)
return hits
def _denylist_hit(
name: str,
*,
base: str,
words: frozenset[str],
denylist: frozenset[str],
) -> DenylistHit | None:
"""First denylist entry matching program *name*, if any."""
for entry in sorted(denylist):
parts = entry.split()
if not parts or basename(parts[0]) != name:
continue
if len(parts) == 1:
return DenylistHit(entry=entry, program=name)
if name != base:
# Argument words describe the program that runs, not a wrapper.
continue
keyword = next((word for word in parts[1:] if word.lower() in words), None)
if keyword is not None:
return DenylistHit(entry=entry, program=name, keyword=keyword.lower())
return None
def _argument_words(command: Sequence[str]) -> frozenset[str]:
"""Lower-cased argument words of *command*, plus ``*.target`` stems."""
words: set[str] = set()
for part in command[1:]:
token = part.lower()
words.add(token)
if token.endswith(".target"):
words.add(token.removesuffix(".target"))
return frozenset(words)
def check_command_allowed(argv: list[str]) -> None:
"""Raise if argv is empty or any program in the command chain is denylisted.
The chain is the program that really runs plus every wrapper it is reached
through (``env FOO=1 rm``, ``pdm run rm``, ``sudo -u x rm``), so a denylisted
program cannot hide behind one. Basenames are compared lower-cased, so
``RM`` and ``rm.exe`` count as ``rm``; entries that list dangerous arguments
(``systemctl reboot …``) match those words in the program's own arguments.
Denylisted programs are not hard-rejected when a policy confirm hook is
installed: the human is asked (with a timeout; default deny). Session grants
skip re-prompting for the same basename. Independent of YOLO soft-confirm.
skip re-prompting for the same program. Independent of YOLO soft-confirm.
"""
shape_error = argv_shape_error(argv)
if shape_error is not None:
@@ -418,25 +512,25 @@ def check_command_allowed(argv: list[str]) -> None:
msg = "command argv must not be empty"
raise WorkspaceError(msg)
policy = active_workspace_policy()
binary = Path(argv[0]).name
if binary not in policy.command_denylist:
return
if binary in policy.policy_allowed_commands:
return
hook = policy.policy_confirm_hook
if hook is not None:
for hit in match_command_denylist(argv, policy.command_denylist):
binary = hit.program
if binary in policy.policy_allowed_commands:
continue
why = f": {hit.keyword!r}" if hit.keyword else ""
hook = policy.policy_confirm_hook
if hook is None:
msg = f"command denied by policy (basename {binary!r} is blocked{why})"
raise WorkspaceError(msg)
timeout = policy.policy_confirm_timeout_seconds
try:
allowed = bool(hook(binary, list(argv), timeout))
except Exception as exc:
msg = f"command denied by policy (basename {binary!r}; confirm failed: {exc})"
raise WorkspaceError(msg) from exc
if allowed:
policy.policy_allowed_commands.add(binary)
return
msg = (
f"command denied by policy (basename {binary!r} is blocked; user declined or timed out after {timeout:g}s)"
)
raise WorkspaceError(msg)
msg = f"command denied by policy (basename {binary!r} is blocked)"
raise WorkspaceError(msg)
if not allowed:
msg = (
f"command denied by policy (basename {binary!r} is blocked{why}; "
f"user declined or timed out after {timeout:g}s)"
)
raise WorkspaceError(msg)
policy.policy_allowed_commands.add(binary)
+129
View File
@@ -211,6 +211,20 @@ async def test_run_chat_loop_text_only() -> None:
assert len(client.calls) == 1
async def test_run_chat_loop_carries_reasoning_effort() -> None:
client = ScriptedClient([_response(AssistantChatMessage(content="hello"))])
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False, reasoning_effort="high")]
assert client.calls[0].reasoning_effort == "high"
async def test_run_chat_loop_omits_unset_reasoning_effort() -> None:
client = ScriptedClient([_response(AssistantChatMessage(content="hello"))])
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
assert client.calls[0].reasoning_effort is UNSET
async def test_non_stream_emits_usage() -> None:
client = ScriptedClient(
[
@@ -639,6 +653,121 @@ async def test_chat_agent_failed_turn_keeps_user_in_db() -> None:
await store.close()
async def test_rollback_keeps_executed_tool_result_in_store() -> None:
"""Rolling back a cut-short batch keeps its executed result, in memory and on disk."""
store = await MemoryStore.open(DatabaseConfig())
session = await store.create_session(name="t")
ran: list[str] = []
@tool(register=False)
def first() -> str:
ran.append("first")
return "first ran"
client = ScriptedClient([_response(AssistantChatMessage(content="finished"))])
agent = ChatAgent(
client,
model="m",
tools=ToolRegistry([first]),
memory=store,
session_id=session.sid,
stream=False,
)
user = UserChatMessage(content="do both")
await store.append_message(session.sid, user)
agent.replace_messages(
[
user,
AssistantChatMessage(
content="running both",
tool_calls=[
AssistantFunctionToolCall(
id="call_1", function=AssistantFunctionTool(name="first", arguments="{}")
),
AssistantFunctionToolCall(
id="call_2", function=AssistantFunctionTool(name="other", arguments="{}")
),
],
),
ToolChatMessage(tool_call_id="call_1", content="first ran"),
],
persist_from=1,
)
await agent._rollback_uncommitted(0) # the failure path itself
kept = [message for message in agent.messages if isinstance(message, ToolChatMessage)]
assert [message.content for message in kept] == ["first ran"]
assert agent.pending_retry_text == "do both"
# What the model continues from is on disk too, or a restart re-runs it.
assert agent.persist_from == len(agent.messages)
stored = await store.list_messages(session.sid)
assert [type(message).__name__ for message in stored] == [
"UserChatMessage",
"AssistantChatMessage",
"ToolChatMessage",
]
record = stored[1]
assert isinstance(record, AssistantChatMessage)
assert record.content is UNSET
calls = record.tool_calls
assert calls is not UNSET
assert [call.id for call in calls] == ["call_1"]
# The retry answers from the stored result instead of running the tool again.
events = [event async for event in agent.retry()]
assert ran == []
assert any(isinstance(event, TextDeltaEvent) and event.content == "finished" for event in events)
sent = client.calls[-1].messages
assert any(isinstance(message, ToolChatMessage) and message.content == "first ran" for message in sent)
await store.close()
async def test_cancelled_batch_records_every_computed_result() -> None:
"""Leaving mid-batch keeps the results that were already computed."""
ran: list[str] = []
@tool(register=False)
def first() -> str:
ran.append("first")
return "first ran"
@tool(register=False)
def second() -> str:
ran.append("second")
return "second ran"
client = ScriptedClient(
[
_response(
AssistantChatMessage(
content="running both",
tool_calls=[
AssistantFunctionToolCall(
id="call_1", function=AssistantFunctionTool(name="first", arguments="{}")
),
AssistantFunctionToolCall(
id="call_2", function=AssistantFunctionTool(name="second", arguments="{}")
),
],
)
)
]
)
agent = ChatAgent(client, model="m", tools=ToolRegistry([first, second]), stream=False)
stream = agent.run("do both")
async for event in stream:
if isinstance(event, ToolResultEvent):
break
await stream.aclose()
results = [message for message in agent.messages if isinstance(message, ToolChatMessage)]
assert [message.content for message in results] == ["first ran", "second ran"]
assert sorted(ran) == ["first", "second"]
async def test_chat_agent_retry_after_failure() -> None:
store = await MemoryStore.open(DatabaseConfig())
session = await store.create_session(name="t")
+124
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from msgspec import UNSET
from plyngent.agent.messages_bridge import (
@@ -11,6 +13,7 @@ from plyngent.agent.messages_bridge import (
anthropic_usage_to_dict,
chat_messages_to_anthropic,
chat_param_to_anthropic_param,
provider_tools_to_anthropic_tools,
tool_items_to_anthropic_tools,
)
from plyngent.lmproto.anthropic.model import (
@@ -18,10 +21,14 @@ from plyngent.lmproto.anthropic.model import (
AnthropicMessageResponse,
AnthropicResponseText,
AnthropicResponseToolUse,
AnthropicServerToolUseContent,
AnthropicThinkingContent,
AnthropicToolDefinition,
AnthropicToolResultContent,
AnthropicToolUseContent,
AnthropicUsage,
AnthropicUserMessage,
AnthropicWebSearchToolResultContent,
)
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
@@ -29,6 +36,7 @@ from plyngent.lmproto.openai_compatible.model import (
AssistantFunctionToolCall,
ChatCompletionsParam,
DeveloperChatMessage,
ReasoningEffort,
SystemChatMessage,
ToolChatMessage,
ToolFunction,
@@ -36,6 +44,9 @@ from plyngent.lmproto.openai_compatible.model import (
UserChatMessage,
)
if TYPE_CHECKING:
from plyngent.typedef import Unset
def test_tool_items_to_anthropic_tools() -> None:
items = [
@@ -145,6 +156,69 @@ def test_anthropic_response_to_assistant_with_tools() -> None:
assert '"a":1' in call.function.arguments or '"a": 1' in call.function.arguments
def test_anthropic_response_to_assistant_ignores_server_tool_blocks() -> None:
"""A server-side search is not a call for the registry, nor part of the message."""
response = AnthropicMessageResponse(
id="msg_search",
model="deepseek-flash",
stop_reason="end_turn",
content=[
AnthropicThinkingContent(thinking="Let me search.", signature="sig-1"),
AnthropicServerToolUseContent(id="call_00_1", name="web_search", input={"query": "DeepSeek-V4"}),
AnthropicWebSearchToolResultContent(
tool_use_id="call_00_1",
content=[{"type": "web_search_result", "url": "https://api-docs.deepseek.com"}],
),
AnthropicResponseText(text="DeepSeek-V4 is out."),
],
usage=AnthropicUsage(input_tokens=16592, output_tokens=1419),
)
assistant = anthropic_response_to_assistant(response)
assert assistant.content == "DeepSeek-V4 is out."
assert assistant.tool_calls is UNSET
def test_provider_tools_to_anthropic_tools_maps_the_search_intent() -> None:
"""The config default (``{"type": "web_search"}``) becomes the server tool."""
assert provider_tools_to_anthropic_tools(None) == []
assert provider_tools_to_anthropic_tools([]) == []
assert provider_tools_to_anthropic_tools([{"type": "web_search"}]) == [
{"type": "web_search_20250305", "name": "web_search"}
]
# A cap (or any other key) survives the mapping.
assert provider_tools_to_anthropic_tools([{"type": "web_search", "max_uses": 3}]) == [
{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}
]
# An entry that already names the surface's tool passes through.
assert provider_tools_to_anthropic_tools([{"type": "web_search_20260209", "name": "web_search"}]) == [
{"type": "web_search_20260209", "name": "web_search"}
]
# Entries without a type are dropped (nothing to declare on the wire).
assert provider_tools_to_anthropic_tools([{"name": "web_search"}]) == []
def test_chat_param_to_anthropic_param_merges_provider_tools_after_local_ones() -> None:
param = ChatCompletionsParam(
model="deepseek-flash",
messages=[UserChatMessage(content="hi")],
tools=[
ToolFunctionItem(
function=ToolFunction(
name="read_file",
description="Read a file.",
parameters={"type": "object", "properties": {}},
)
)
],
)
built = chat_param_to_anthropic_param(param, provider_tools=[{"type": "web_search"}])
tools = built.tools
assert tools is not UNSET
assert isinstance(tools[0], AnthropicToolDefinition)
assert tools[0].name == "read_file"
assert tools[1] == {"type": "web_search_20250305", "name": "web_search"}
def test_anthropic_response_to_chat_completion_usage() -> None:
response = AnthropicMessageResponse(
id="msg_1",
@@ -198,3 +272,53 @@ def test_chat_param_to_anthropic_param() -> None:
assert body.tools is not UNSET
assert len(body.tools) == 1
assert body.temperature == 0.2
# No effort configured → no thinking block, provider default applies.
assert body.thinking is UNSET
def _effort_param(effort: ReasoningEffort | Unset = UNSET) -> ChatCompletionsParam:
return ChatCompletionsParam(
model="claude-test",
messages=[UserChatMessage(content="hi")],
reasoning_effort=effort,
)
def test_chat_param_to_anthropic_param_thinking_from_effort() -> None:
body = chat_param_to_anthropic_param(_effort_param("medium"))
assert body.thinking is not UNSET
assert body.thinking.type == "enabled"
assert body.thinking.budget_tokens == 8192
# A budget equal to max_tokens is invalid for Anthropic, so the default
# output limit grows to leave room after it.
assert body.max_tokens == 8192 + 1024
def test_chat_param_to_anthropic_param_thinking_raises_max_tokens() -> None:
# Anthropic requires budget_tokens < max_tokens; the answer keeps room after
# the reasoning budget instead of the budget being shrunk to fit.
body = chat_param_to_anthropic_param(_effort_param("high"))
assert body.thinking is not UNSET
assert body.thinking.budget_tokens == 16384
assert body.max_tokens == 16384 + 1024
def test_chat_param_to_anthropic_param_explicit_thinking_budget() -> None:
body = chat_param_to_anthropic_param(_effort_param("minimal"), thinking_budget_tokens=20000)
assert body.thinking is not UNSET
assert body.thinking.budget_tokens == 20000
assert body.max_tokens == 20000 + 1024
def test_chat_param_to_anthropic_param_thinking_budget_floor() -> None:
body = chat_param_to_anthropic_param(_effort_param("minimal"), thinking_budget_tokens=100)
assert body.thinking is not UNSET
assert body.thinking.budget_tokens == 1024
assert body.max_tokens == 8192
def test_chat_param_to_anthropic_param_thinking_disabled() -> None:
body = chat_param_to_anthropic_param(_effort_param("none"))
assert body.thinking is not UNSET
assert body.thinking.type == "disabled"
assert body.thinking.budget_tokens is UNSET
+182
View File
@@ -20,6 +20,7 @@ from plyngent.agent.messages_dispatch import dispatch_messages
from plyngent.lmproto.anthropic.model import (
AnthropicContentBlockDelta,
AnthropicContentBlockStart,
AnthropicContentBlockStop,
AnthropicMessageDelta,
AnthropicMessageResponse,
AnthropicMessagesParam,
@@ -71,6 +72,8 @@ class ScriptedMessagesClient:
kind: str = "messages"
calls: list[AnthropicMessagesParam]
# Mirrors AnthropicClient: explicit thinking budget (0 = derive from effort).
thinking_budget_tokens: int
_non_stream: list[AnthropicMessageResponse]
_stream_events: list[list[AnthropicStreamEvent]]
@@ -81,6 +84,7 @@ class ScriptedMessagesClient:
stream_events: Sequence[Sequence[AnthropicStreamEvent]] | None = None,
) -> None:
self.calls = []
self.thinking_budget_tokens = 0
self._non_stream = list(non_stream or ())
self._stream_events = [list(events) for events in (stream_events or ())]
@@ -186,6 +190,46 @@ async def test_run_chat_loop_messages_kind_text() -> None:
assert any(isinstance(e, UsageEvent) and e.usage.prompt_tokens == 10 for e in events)
assert isinstance(messages[-1], AssistantChatMessage)
assert messages[-1].content == "done"
# No thinking configured → the request carries no thinking block.
assert client.calls[0].thinking is UNSET
async def test_run_chat_loop_passes_reasoning_effort_and_client_thinking_budget() -> None:
client = ScriptedMessagesClient(non_stream=[_message_response(text="done")])
client.thinking_budget_tokens = 12000
events = [
event
async for event in run_chat_loop(
cast("Any", client),
[UserChatMessage(content="go")],
model="claude-test",
stream=False,
reasoning_effort="high",
)
]
assert any(isinstance(e, TextDeltaEvent) for e in events)
thinking = client.calls[0].thinking
assert thinking is not UNSET
assert thinking.type == "enabled"
# The client's explicit budget wins over the level's derived one.
assert thinking.budget_tokens == 12000
async def test_run_chat_loop_reasoning_effort_none_disables_thinking() -> None:
client = ScriptedMessagesClient(non_stream=[_message_response(text="done")])
_ = [
event
async for event in run_chat_loop(
cast("Any", client),
[UserChatMessage(content="go")],
model="claude-test",
stream=False,
reasoning_effort="none",
)
]
thinking = client.calls[0].thinking
assert thinking is not UNSET
assert thinking.type == "disabled"
async def test_run_chat_loop_messages_kind_tool_round() -> None:
@@ -350,3 +394,141 @@ async def test_dispatch_stream_tool_calls_without_stop_yields_no_finish() -> Non
chunks = [chunk async for chunk in cast("Any", stream)]
# tool-call start delta only; no fabricated finish_reason=tool_calls
assert not any(c.choices and c.choices[0].finish_reason not in (None, UNSET) for c in chunks)
def _search_stream_events() -> list[AnthropicStreamEvent]:
"""The stream a DeepSeek search turn sends: thinking, search, results, text."""
return [
AnthropicMessageStart(
message=AnthropicMessageResponse(
id="msg_s",
model="deepseek-flash",
content=[],
usage=AnthropicUsage(input_tokens=164, output_tokens=0),
)
),
AnthropicContentBlockStart(index=0, content_block=AnthropicRawContentBlock(type="thinking", text="")),
AnthropicContentBlockDelta(
index=0,
delta=AnthropicRawContentBlock(type="thinking_delta", text="Search."),
),
AnthropicContentBlockStop(index=0),
AnthropicContentBlockStart(
index=1,
content_block=AnthropicRawContentBlock(type="server_tool_use", id="call_00_1", name="web_search", input={}),
),
AnthropicContentBlockDelta(
index=1,
delta=AnthropicRawContentBlock(type="input_json_delta", partial_json='{"query":'),
),
AnthropicContentBlockDelta(
index=1,
delta=AnthropicRawContentBlock(type="input_json_delta", partial_json='"DeepSeek-V4"}'),
),
AnthropicContentBlockStop(index=1),
AnthropicContentBlockStart(
index=2,
content_block=AnthropicRawContentBlock(
type="web_search_tool_result",
id="call_00_1",
input={"content": [{"type": "web_search_result", "url": "https://api-docs.deepseek.com"}]},
),
),
AnthropicContentBlockStop(index=2),
AnthropicContentBlockStart(index=3, content_block=AnthropicRawContentBlock(type="text", text="")),
AnthropicContentBlockDelta(
index=3,
delta=AnthropicRawContentBlock(type="text_delta", text="DeepSeek-V4 is out."),
),
AnthropicContentBlockStop(index=3),
AnthropicMessageDelta(
delta={"stop_reason": "end_turn"},
usage=AnthropicUsage(input_tokens=0, output_tokens=12),
),
AnthropicMessageStop(),
]
async def test_search_turn_streams_its_text_without_a_local_tool_call() -> None:
"""A server tool's arguments must not become a call for the registry.
DeepSeek streams a ``server_tool_use`` block whose ``input_json_delta``
fragments carry the search query. Reading those as a local call's arguments
asks the registry for a tool the *server* already ran — and would have the
agent execute ``web_search`` (or a nameless tool) itself.
"""
client = ScriptedMessagesClient(stream_events=[_search_stream_events()])
@tool(register=False)
def echo(text: str) -> str:
return text
events = [
event
async for event in run_chat_loop(
cast("Any", client),
[UserChatMessage(content="what is DeepSeek-V4?")],
model="deepseek-flash",
tools=ToolRegistry([echo]),
stream=True,
)
]
assert not any(isinstance(event, ToolCallEvent) for event in events)
assert not any(isinstance(event, ToolResultEvent) for event in events)
assert any(isinstance(event, TextDeltaEvent) and event.content == "DeepSeek-V4 is out." for event in events)
# One round: the search answered inside the response, so nothing was sent back.
assert len(client.calls) == 1
async def test_dispatch_messages_sends_provider_tools_as_server_tools() -> None:
"""Hosted tools reach the Anthropic request in that surface's own shape."""
client = ScriptedMessagesClient(non_stream=[_message_response(text="ok")])
param = ChatCompletionsParam(model="deepseek-flash", messages=[UserChatMessage(content="hi")])
_ = await dispatch_messages(
cast("Any", client),
param,
provider_tools=[{"type": "web_search", "max_uses": 3}],
)
assert client.calls[0].tools == [{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}]
async def test_dispatch_messages_without_provider_tools_sends_no_server_tool() -> None:
client = ScriptedMessagesClient(non_stream=[_message_response(text="ok")])
param = ChatCompletionsParam(model="deepseek-flash", messages=[UserChatMessage(content="hi")])
_ = await dispatch_messages(cast("Any", client), param, provider_tools=[])
assert client.calls[0].tools is UNSET
async def test_search_turn_reports_the_prompt_the_delta_counts() -> None:
"""A search turn's real prompt size only shows up on ``message_delta``.
DeepSeek's compat opens with the question alone (``message_start`` sees 165
tokens) and reports what the search then pulled in on the delta (12468);
keeping the smaller one leaves the CLI's context gauge — and the
auto-compact threshold — that far short of what the model was sent.
"""
events: list[AnthropicStreamEvent] = [
AnthropicMessageStart(
message=AnthropicMessageResponse(
id="msg_s",
model="deepseek-flash",
content=[],
usage=AnthropicUsage(input_tokens=165, output_tokens=0),
)
),
AnthropicContentBlockStart(index=0, content_block=AnthropicRawContentBlock(type="text", text="")),
AnthropicContentBlockDelta(index=0, delta=AnthropicRawContentBlock(type="text_delta", text="done")),
AnthropicMessageDelta(
delta={"stop_reason": "end_turn"},
usage=AnthropicUsage(input_tokens=12468, output_tokens=198),
),
AnthropicMessageStop(),
]
client = ScriptedMessagesClient(stream_events=[events])
param = ChatCompletionsParam(model="deepseek-flash", messages=[UserChatMessage(content="hi")])
stream = await dispatch_messages(cast("Any", client), param, stream=True)
chunks = [chunk async for chunk in cast("Any", stream)]
usage = next(getattr(chunk, "usage", None) for chunk in chunks if getattr(chunk, "usage", None))
assert isinstance(usage, dict)
assert usage["prompt_tokens"] == 12468
assert usage["completion_tokens"] == 198
+293 -13
View File
@@ -13,12 +13,13 @@ from plyngent.agent.responses_bridge import (
responses_status_to_finish_reason,
tool_items_to_response_tools,
)
from plyngent.lmproto.openai.model import Response, ResponseFunctionToolCallOutput
from plyngent.lmproto.openai.model import Response, ResponseEasyInputMessage, ResponseFunctionToolCallOutput
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
AssistantFunctionTool,
AssistantFunctionToolCall,
ChatCompletionsParam,
DeveloperChatMessage,
SystemChatMessage,
ToolChatMessage,
ToolFunction,
@@ -27,6 +28,18 @@ from plyngent.lmproto.openai_compatible.model import (
)
def _input_item_types(
items: list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput],
) -> list[str]:
"""Item ``type`` tags, in stream order."""
types: list[str] = []
for item in items:
tag = item.get("type") if isinstance(item, dict) else item.__struct_config__.tag
assert isinstance(tag, str)
types.append(tag)
return types
def test_tool_items_to_response_tools() -> None:
items = [
ToolFunctionItem(
@@ -59,14 +72,18 @@ def test_chat_messages_to_input_and_instructions() -> None:
]
instructions, items = chat_messages_to_responses_input(messages)
assert instructions == "You are helpful."
assert len(items) == 3 # user, function_call, function_call_output
# user message, then the call turn's reasoning + call, then its output.
assert _input_item_types(items) == ["message", "reasoning", "function_call", "function_call_output"]
def test_parallel_tool_outputs_interleaved_after_calls() -> None:
"""Regression: DeepSeek Responses rejects call, call, output, output batches.
def test_parallel_tool_outputs_group_after_the_rounds_calls() -> None:
"""Regression: DeepSeek Responses rejected an interleaved batch.
Each ``function_call_output`` must directly follow its ``function_call`` so
strict/adjacency-matching servers (DeepSeek) accept parallel tool rounds.
A round is the assistant message its ``reasoning`` and ``function_call``
items merge into, so its calls must stay contiguous: sent as
``call, output, call, output`` the second call reads as a round of its own
without reasoning, and the request comes back 400 ("The ``reasoning_text`` in
the thinking mode must be passed back to the API").
"""
messages = [
UserChatMessage(content="run both"),
@@ -87,19 +104,20 @@ def test_parallel_tool_outputs_interleaved_after_calls() -> None:
ToolChatMessage(content="grep hit", tool_call_id="call_02_ABCD"),
]
_, items = chat_messages_to_responses_input(messages)
types = [item.get("type") if isinstance(item, dict) else item.__struct_config__.tag for item in items]
types = _input_item_types(items)
assert types == [
"message",
"reasoning",
"function_call",
"function_call",
"function_call_output",
"function_call",
"function_call_output",
]
# Each output is paired with its own call, in stream order.
call_a = items[1]
out_a = items[2]
# Each output follows the round's calls, paired with its own call.
call_a = items[2]
call_b = items[3]
out_b = items[4]
out_a = items[4]
out_b = items[5]
assert isinstance(call_a, dict) and isinstance(call_b, dict)
assert isinstance(out_a, ResponseFunctionToolCallOutput)
assert isinstance(out_b, ResponseFunctionToolCallOutput)
@@ -107,6 +125,221 @@ def test_parallel_tool_outputs_interleaved_after_calls() -> None:
assert out_b.call_id == call_b["call_id"]
def test_tool_output_after_another_item_still_follows_its_call() -> None:
"""A notice recorded between a call and its output does not split the pair.
The output is moved up beside the calls of its round; left where it was it
would trail the notice's assistant message and be answered with "No tool
output found for tool call …".
"""
messages = [
AssistantChatMessage(
content=None,
reasoning_content="Read the file.",
tool_calls=[
AssistantFunctionToolCall(
id="call_1",
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
)
],
),
DeveloperChatMessage(content="reminder: keep going"),
ToolChatMessage(content="file body", tool_call_id="call_1"),
]
_, items = chat_messages_to_responses_input(messages)
assert _input_item_types(items) == [
"reasoning",
"function_call",
"function_call_output",
"message",
]
assert items[2] == ResponseFunctionToolCallOutput(call_id="call_1", output="file body")
def test_tool_call_turn_carries_its_reasoning() -> None:
"""Regression: DeepSeek rejected the tool-call round with a 422.
The reasoning item leads, directly ahead of the call it produced — DeepSeek
merges it into that call's assistant message. Its ``content`` is a list of
``reasoning_text`` parts, the only part the API reference documents for a
``reasoning`` input item (a bare string is a deserialization error).
"""
messages = [
UserChatMessage(content="read it"),
AssistantChatMessage(
content=None,
reasoning_content="Open the file first.",
tool_calls=[
AssistantFunctionToolCall(
id="call_1",
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
)
],
),
ToolChatMessage(content="file body", tool_call_id="call_1"),
]
_, items = chat_messages_to_responses_input(messages)
assert _input_item_types(items) == ["message", "reasoning", "function_call", "function_call_output"]
assert items[1] == {
"type": "reasoning",
"content": [{"type": "reasoning_text", "text": "Open the file first."}],
}
def test_parallel_call_turn_keeps_one_reasoning_item() -> None:
"""A batch keeps the round's single reasoning item, ahead of its calls."""
messages = [
AssistantChatMessage(
content=None,
reasoning_content="Both are independent.",
tool_calls=[
AssistantFunctionToolCall(
id="call_a",
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
),
AssistantFunctionToolCall(
id="call_b",
function=AssistantFunctionTool(name="regex_files", arguments='{"pattern":"todo"}'),
),
],
),
ToolChatMessage(content="file body", tool_call_id="call_a"),
ToolChatMessage(content="grep hit", tool_call_id="call_b"),
]
_, items = chat_messages_to_responses_input(messages)
assert _input_item_types(items) == [
"reasoning",
"function_call",
"function_call",
"function_call_output",
"function_call_output",
]
assert items[0] == {
"type": "reasoning",
"content": [{"type": "reasoning_text", "text": "Both are independent."}],
}
def test_round_with_text_and_a_call_puts_the_text_before_the_call() -> None:
"""A call round ends on its tool output, never on a trailing text message.
A request that ended with the round's text *after* its own output was
answered with a 400 ("the `reasoning_text` … must be passed back"): the
text has to stay with its reasoning, ahead of the calls.
"""
messages = [
UserChatMessage(content="read it"),
AssistantChatMessage(
content="Reading the file now.",
reasoning_content="Open the file first.",
tool_calls=[
AssistantFunctionToolCall(
id="call_1",
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
)
],
),
ToolChatMessage(content="file body", tool_call_id="call_1"),
]
_, items = chat_messages_to_responses_input(messages)
assert _input_item_types(items) == ["message", "reasoning", "message", "function_call", "function_call_output"]
def test_answer_round_without_reasoning_sends_a_blank_part() -> None:
"""A full part is required, not just the item.
An empty ``reasoning_text`` is answered with the same 400 as a missing item,
so a round that kept no chain-of-thought — the seed a compacted session
starts with, a round recorded before the reasoning was read back — sends a
blank line rather than words of the bridge's own. The rule is per round: an
answer that called nothing is checked too.
"""
messages = [
UserChatMessage(content="hi"),
AssistantChatMessage(content="hello"),
]
_, items = chat_messages_to_responses_input(messages)
assert _input_item_types(items) == ["message", "reasoning", "message"]
assert items[1] == {
"type": "reasoning",
"content": [{"type": "reasoning_text", "text": " "}],
}
def test_answer_turn_with_reasoning_carries_it() -> None:
"""A round that called nothing still hands its reasoning back.
Thinking Mode → Tool Calls: a request carrying ``tools`` must return the
reasoning of every round, "even if the round did not actually call a tool";
a missing one is a 400.
"""
messages = [
UserChatMessage(content="hi"),
AssistantChatMessage(content="hello", reasoning_content="They greet me, so I greet back."),
]
_, items = chat_messages_to_responses_input(messages)
assert _input_item_types(items) == ["message", "reasoning", "message"]
assert items[1] == {
"type": "reasoning",
"content": [{"type": "reasoning_text", "text": "They greet me, so I greet back."}],
}
def test_forged_call_round_sends_a_blank_part() -> None:
"""A synthetic nag assistant carries ``reasoning_content=""``.
The round still needs reasoning text, so a blank line goes back with it
rather than an empty part (which the API rejects just like a missing item).
"""
messages = [
AssistantChatMessage(
content=None,
reasoning_content="",
tool_calls=[
AssistantFunctionToolCall(
id="call_nag_1",
function=AssistantFunctionTool(name="todo_list", arguments="{}"),
)
],
),
ToolChatMessage(content="stack", tool_call_id="call_nag_1"),
]
_, items = chat_messages_to_responses_input(messages)
assert _input_item_types(items) == ["reasoning", "function_call", "function_call_output"]
assert items[0] == {
"type": "reasoning",
"content": [{"type": "reasoning_text", "text": " "}],
}
def test_rolled_back_call_round_sends_a_blank_part() -> None:
"""A kept call that recorded no reasoning still carries reasoning text.
History from before the chain-of-thought was read back, and a carrier
``rollback_tail`` wrote without one, hold a call with no reasoning at all;
the round must return text, so a blank line goes back with it (both a
missing item and an empty part are a 400).
"""
messages = [
AssistantChatMessage(
content=None,
tool_calls=[
AssistantFunctionToolCall(
id="call_kept",
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
)
],
),
ToolChatMessage(content="file body", tool_call_id="call_kept"),
]
_, items = chat_messages_to_responses_input(messages)
assert _input_item_types(items) == ["reasoning", "function_call", "function_call_output"]
assert items[0] == {
"type": "reasoning",
"content": [{"type": "reasoning_text", "text": " "}],
}
def test_response_to_assistant_with_tools() -> None:
raw = {
"id": "resp_1",
@@ -167,7 +400,7 @@ def _reasoning_response(
return Response(
id="resp_r",
created_at=1,
model="deepseek-v4-flash",
model="deepseek-flash",
output=output,
reasoning=top_level,
)
@@ -194,6 +427,41 @@ def test_reasoning_summary_text_falls_back_to_output_items() -> None:
assert reasoning_summary_text(response) == "openai-style reasoning"
def test_reasoning_summary_text_reads_output_item_content() -> None:
"""DeepSeek's reasoning item carries the chain-of-thought in ``content``.
The parts are ``reasoning_text`` and ``summary`` comes back empty (see the
API reference sample), so reading ``summary`` alone finds nothing — and a
tool-call turn then goes back without the reasoning thinking mode wants.
"""
body = {
"id": "resp_c",
"object": "response",
"created_at": 1,
"model": "deepseek-flash",
"status": "completed",
"output": [
{
"type": "reasoning",
"id": "rs_1",
"status": "completed",
"content": [{"type": "reasoning_text", "text": "The user greets me."}],
"summary": [],
},
{
"type": "function_call",
"call_id": "call_1",
"name": "read_file",
"arguments": '{"path":"a"}',
"status": "completed",
},
],
}
response = msgspec.convert(body, Response)
assert reasoning_summary_text(response) == "The user greets me."
assert response_to_assistant_message(response).reasoning_content == "The user greets me."
def test_reasoning_summary_text_empty_when_absent() -> None:
assert reasoning_summary_text(_reasoning_response()) == ""
@@ -235,6 +503,18 @@ def test_chat_param_to_responses_kwargs() -> None:
assert kwargs["store"] is False
assert kwargs["temperature"] == 0.2
assert len(kwargs["tools"]) == 1
# No effort configured → no reasoning block.
assert "reasoning" not in kwargs
def test_chat_param_to_responses_kwargs_reasoning_effort() -> None:
param = ChatCompletionsParam(
model="gpt-test",
messages=[UserChatMessage(content="hi")],
reasoning_effort="xhigh",
)
kwargs = chat_param_to_responses_kwargs(param)
assert kwargs["reasoning"].effort == "xhigh"
def test_provider_tools_merged_after_local_functions() -> None:
+61 -1
View File
@@ -9,6 +9,7 @@ from msgspec import UNSET
from plyngent.agent import (
AssistantMessageEvent,
ReasoningDeltaEvent,
TextDeltaEvent,
ToolCallEvent,
ToolRegistry,
@@ -258,6 +259,65 @@ async def test_dispatch_responses_stream_reasoning_no_duplicate() -> None:
assert reasoning == "streamed only"
async def _loop_reasoning_deltas(
events: list[ResponseStreamEvent],
messages: list[Any],
) -> list[str]:
"""Run one streamed round and collect the reasoning deltas the loop emitted."""
import msgspec
final = _completed_response(text="ok")
scripted = [*events, ResponseStreamEvent(type="response.completed", response=msgspec.to_builtins(final))]
client = ScriptedResponsesClient(stream_events=[scripted])
return [
event.content
async for event in run_chat_loop(cast("Any", client), messages, model="gpt-test", stream=True)
if isinstance(event, ReasoningDeltaEvent)
]
async def test_loop_responses_reasoning_done_is_not_printed_twice() -> None:
"""``response.reasoning_text.done`` repeats what its fragments spelled out.
Streaming it verbatim printed the chain of thought twice (the fragments, then
the whole text); the round still hands the full text back to the API.
"""
fragments = ["We", " need", " answer."]
events = [ResponseStreamEvent(type="response.reasoning_text.delta", delta=part) for part in fragments]
events.append(ResponseStreamEvent(type="response.reasoning_text.done", text="We need answer."))
messages: list[Any] = [UserChatMessage(content="hi")]
deltas = await _loop_reasoning_deltas(events, messages)
assert deltas == fragments
assert messages[-1].reasoning_content == "We need answer."
async def test_loop_responses_reasoning_done_alone_is_shown() -> None:
"""A round whose fragments never arrived still shows the ``.done`` text once."""
events = [ResponseStreamEvent(type="response.reasoning_text.done", text="chain of thought")]
messages: list[Any] = [UserChatMessage(content="hi")]
deltas = await _loop_reasoning_deltas(events, messages)
assert deltas == ["chain of thought"]
assert messages[-1].reasoning_content == "chain of thought"
async def test_loop_responses_reasoning_done_extends_fragments() -> None:
"""A ``.done`` text longer than what streamed shows only its unseen tail."""
events = [
ResponseStreamEvent(type="response.reasoning_text.delta", delta="We need"),
ResponseStreamEvent(type="response.reasoning_text.done", text="We need answer."),
]
messages: list[Any] = [UserChatMessage(content="hi")]
deltas = await _loop_reasoning_deltas(events, messages)
assert deltas == ["We need", " answer."]
assert messages[-1].reasoning_content == "We need answer."
def token_usage_present(chunk: object) -> bool:
usage = getattr(chunk, "usage", UNSET)
return usage is not UNSET and usage is not None
@@ -430,7 +490,7 @@ async def test_dispatch_stream_failed_raises_with_error_detail() -> None:
"id": "resp_1",
"object": "response",
"created_at": 1,
"model": "deepseek-v4-flash",
"model": "deepseek-flash",
"status": "failed",
"output": [],
"error": {"code": "rate_limit_exceeded", "message": "slow down"},
+104
View File
@@ -0,0 +1,104 @@
"""Rollback of a failed turn keeps the tool work that already ran.
``committed_prefix_end`` commits a batch only once every call has its result, so
``rollback_tail`` keeps the executed part of a batch that was cut short: the
retry then continues from real results instead of running those tools again.
"""
from __future__ import annotations
from msgspec import UNSET
from plyngent.agent.chat import rollback_tail
from plyngent.lmproto.openai_compatible.model import (
AnyChatMessage,
AssistantChatMessage,
AssistantFunctionTool,
AssistantFunctionToolCall,
DeveloperChatMessage,
ToolChatMessage,
UserChatMessage,
)
def _call(call_id: str, name: str = "some_tool") -> AssistantFunctionToolCall:
return AssistantFunctionToolCall(id=call_id, function=AssistantFunctionTool(name=name, arguments="{}"))
def test_rollback_tail_keeps_the_executed_call_of_a_cut_short_batch() -> None:
messages: list[AnyChatMessage] = [
UserChatMessage(content="do both"),
AssistantChatMessage(
content="first, then the other",
reasoning_content="step by step",
tool_calls=[_call("call_a"), _call("call_b")],
),
ToolChatMessage(tool_call_id="call_a", content="a ran"),
]
tail = rollback_tail(messages, 1)
assert len(tail) == 2
record, result = tail
assert isinstance(record, AssistantChatMessage)
# The assistant row survives as the carrier of the pairing, with the
# reasoning thinking mode demands on a call.
assert record.content is UNSET
assert record.reasoning_content == "step by step"
calls = record.tool_calls
assert calls is not UNSET
assert [call.id for call in calls] == ["call_a"]
assert isinstance(result, ToolChatMessage)
assert result.content == "a ran"
def test_rollback_tail_drops_a_batch_that_reported_nothing() -> None:
messages: list[AnyChatMessage] = [
UserChatMessage(content="go"),
AssistantChatMessage(content="about to call", tool_calls=[_call("call_a")]),
]
assert rollback_tail(messages, 1) == []
def test_rollback_tail_drops_unfinished_text_and_keeps_developer_rows() -> None:
messages: list[AnyChatMessage] = [
UserChatMessage(content="go"),
AssistantChatMessage(content=UNSET, tool_calls=[_call("call_a")]),
ToolChatMessage(tool_call_id="call_a", content="a ran"),
DeveloperChatMessage(content="checkpoint"),
AssistantChatMessage(content="half-written answer"),
]
tail = rollback_tail(messages, 1)
assert [type(message) for message in tail] == [AssistantChatMessage, ToolChatMessage, DeveloperChatMessage]
def test_rollback_tail_pairs_a_repeated_call_id_once() -> None:
messages: list[AnyChatMessage] = [
UserChatMessage(content="go"),
AssistantChatMessage(content=UNSET, tool_calls=[_call("call_a"), _call("call_a")]),
ToolChatMessage(tool_call_id="call_a", content="only once"),
]
tail = rollback_tail(messages, 1)
assert len(tail) == 2
record, result = tail
assert isinstance(record, AssistantChatMessage)
calls = record.tool_calls
assert calls is not UNSET
assert [call.id for call in calls] == ["call_a"]
assert isinstance(result, ToolChatMessage)
assert result.content == "only once"
def test_rollback_tail_ignores_rows_before_start() -> None:
messages: list[AnyChatMessage] = [
UserChatMessage(content="go"),
AssistantChatMessage(content=UNSET, tool_calls=[_call("call_a")]),
ToolChatMessage(tool_call_id="call_a", content="a ran"),
]
assert rollback_tail(messages, len(messages)) == []
+139 -23
View File
@@ -7,6 +7,7 @@ from plyngent.cli.display import (
_clear_streamed_lines,
_line_count_for_clear,
_pretty_line_for,
close_open_pretty_line,
get_markdown_enabled,
markdown_render_available,
print_markdown,
@@ -185,6 +186,51 @@ async def test_pretty_todo_push(capsys: pytest.CaptureFixture[str]) -> None:
assert "[tool]" not in out
async def test_pretty_todo_batch_merges_to_one_stack(capsys: pytest.CaptureFixture[str]) -> None:
"""A batch of todo calls: one summary line, then the last result's stack."""
stack = "(LIFO of groups: depth=1; TOP group = current breakdown level)\ngroup d=0 TOP:\n [ ] a1: T1"
await render_events(
_aiter(
[
_pretty_call("todo_push", '{"titles": ["T1"]}'),
_pretty_call("todo_update", '{"item_id": "a1", "status": "done"}'),
_result(f"pushed group (depth=1) items=[a1]\n{stack}"),
_result(f"updated a1 → done: T1\n{stack}"),
]
)
)
out = capsys.readouterr().out
assert "* Todo x2: Push, Update" in out
assert "* Todo Push:" not in out
assert "* Todo Update:" not in out
# Only the last result is printed, so the stack renders once.
assert out.count("group d=0 TOP:") == 1
assert "pushed group" not in out
assert "updated a1 → done: T1" in out
async def test_pretty_todo_batch_mixed_with_other_tool_stays_per_call(
capsys: pytest.CaptureFixture[str],
) -> None:
"""A batch holding a non-todo call keeps one line (and stack) per todo call."""
stack = "group d=0 TOP:\n [ ] a1: T1"
await render_events(
_aiter(
[
_pretty_call("todo_push", '{"titles": ["T1"]}'),
_pretty_call("listdir", '{"path": "src"}'),
_result(f"pushed group (depth=1) items=[a1]\n{stack}"),
_result("dir\tsrc\nfile\tREADME.md\n"),
]
)
)
out = capsys.readouterr().out
assert "* Todo Push:" in out
assert "group d=0 TOP:" in out
assert "src" in out
assert "* Todo x" not in out
async def test_non_pretty_tool_keeps_old_style(capsys: pytest.CaptureFixture[str]) -> None:
"""Tools with no pretty line (plugins, unknown names) keep the ``[tool]`` style."""
call = ToolCallEvent(
@@ -463,6 +509,42 @@ async def test_pretty_fetch_error(capsys: pytest.CaptureFixture[str]) -> None:
assert "* Fetch GET https://example.com (error: fetch failed: boom)" in out
async def test_pretty_web_search(capsys: pytest.CaptureFixture[str]) -> None:
result = (
"- CPython free-threading — https://example.com/ft\n"
" PEP 703 landed in 3.13\n"
"- Another page — https://example.com/other\n"
)
call = _pretty_call("web_search", '{"query": "python 3.14 free-threading"}')
await render_events(_aiter([call, _result(result)]))
out = capsys.readouterr().out
assert "* Web Search 'python 3.14 free-threading' (2 hits)" in out
assert "[tool]" not in out
assert "[tool ok]" not in out
async def test_pretty_web_search_truncates_the_query(capsys: pytest.CaptureFixture[str]) -> None:
query = "a" * 80
await render_events(
_aiter([_pretty_call("web_search", f'{{"query": "{query}"}}'), _result("- One — https://example.com\n")])
)
out = capsys.readouterr().out
assert f"* Web Search '{'a' * 60}…' (1 hit)" in out
async def test_pretty_web_search_no_results(capsys: pytest.CaptureFixture[str]) -> None:
result = "error: the search found nothing (DeepseekSearchBackend: no results; BingSearchBackend: no results)"
await render_events(_aiter([_pretty_call("web_search", '{"query": "zzz"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Web Search 'zzz' (no results)" in out
async def test_pretty_web_search_failing_source(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_pretty_call("web_search", '{"query": "x"}'), _result("error: search HTTP 401")]))
out = capsys.readouterr().out
assert "* Web Search 'x' (error: search HTTP 401)" in out
async def test_pretty_mutators(capsys: pytest.CaptureFixture[str]) -> None:
cases = [
("write_file", '{"path": "src/x.py"}', "wrote 120 characters to src/x.py", "* Write 'src/x.py' (120 chars)"),
@@ -816,8 +898,13 @@ async def test_pretty_mcp_tools_use_one_generic_line(capsys: pytest.CaptureFixtu
def test_every_builtin_tool_has_a_pretty_line() -> None:
"""A new builtin tool must ship a renderer, or it silently loses the syntax."""
from plyngent.tools.catalog import default_tool_definitions
from plyngent.tools.net.search import WEB_SEARCH_TOOL_NAME
missing = [d.name for d in default_tool_definitions() if _pretty_line_for(d.name) is None]
names = [d.name for d in default_tool_definitions()]
# The CLI host composes ``web_search`` over the builtins, so a catalog walk
# on its own would let a renderer-less tool ship.
names.append(WEB_SEARCH_TOOL_NAME)
missing = [name for name in names if _pretty_line_for(name) is None]
assert missing == []
@@ -951,11 +1038,11 @@ async def test_pretty_verbose_keeps_full_result_output(
assert "* Read 'a.txt' " not in out
async def test_pretty_parallel_batch_erases_open_prefix(
async def test_pretty_parallel_batch_gives_every_call_its_own_row(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""A second call in the batch erases the open prefix; results print whole."""
"""A second call in the batch holds its prefix until the first row is done."""
monkeypatch.setattr("plyngent.cli.display.interactive_terminal", lambda: True)
await render_events(
_aiter(
@@ -968,20 +1055,39 @@ async def test_pretty_parallel_batch_erases_open_prefix(
)
)
out = capsys.readouterr().out
assert "\x1b[2K\x1b[1A" in out # the in-flight prefix was erased
assert "* Read 'a.txt' L1-4 (done)\n" in out
assert "* Read 'b.txt' L1-9 (done)\n" in out
assert out == ("\n* Read 'a.txt' L1-4 (done)\n\n* Read 'b.txt' L1-9 (done)\n\n")
assert "\x1b[" not in out # the cursor is never moved
async def test_pretty_edit_batch_prints_one_row_per_edit(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Two edits in one round: one finished row each, no abandoned prefix row."""
monkeypatch.setattr("plyngent.cli.display.interactive_terminal", lambda: True)
await render_events(
_aiter(
[
_pretty_call("edit_replace", '{"path": "a.py"}'),
_pretty_call("edit_replace", '{"path": "b.py"}'),
_result("replaced 1 occurrence in a.py ('x' → 'y')"),
_result("replaced 1 occurrence in b.py ('x' → 'y')"),
]
)
)
out = capsys.readouterr().out
assert out == "\n* Edit 'a.py' (done)\n\n* Edit 'b.py' (done)\n\n"
async def test_pretty_parallel_batch_keeps_the_line_above_the_prefix(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Regression: erasing the open prefix must not eat the line above it.
"""Rendering a batch must not touch the line above the prefix.
Reasoning streams without a trailing newline, so the prefix line sits right
below the reasoning text; the erase used to clear one row too many and wiped
that reasoning line out of the screen.
below the reasoning text; moving the cursor up used to wipe that reasoning
line out of the screen.
"""
monkeypatch.setattr("plyngent.cli.display.interactive_terminal", lambda: True)
await render_events(
@@ -996,14 +1102,30 @@ async def test_pretty_parallel_batch_keeps_the_line_above_the_prefix(
)
)
out = capsys.readouterr().out
assert out == (
"\nreasoning:\nthink"
"\n* Read 'a.txt' "
"\r\x1b[2K\x1b[1A"
"\n* Read 'a.txt' L1-4 (done)\n"
"\n* Read 'b.txt' L1-9 (done)\n"
"\n\n"
)
assert out == ("\nreasoning:\nthink\n* Read 'a.txt' L1-4 (done)\n\n* Read 'b.txt' L1-9 (done)\n\n\n")
assert "\x1b[" not in out
async def test_pretty_line_closed_by_out_of_band_output(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""A prompt/confirm printed while the call runs ends the open prefix line.
The prompt backend runs :func:`close_open_pretty_line` before writing, so its
box starts on a fresh line and the result then prints whole.
"""
monkeypatch.setattr("plyngent.cli.display.interactive_terminal", lambda: True)
async def _events():
yield _pretty_call("read_file", '{"path": "a.txt"}')
close_open_pretty_line() # what a confirm box does mid-call
yield _result("L1-4\none\n")
await render_events(_events())
out = capsys.readouterr().out
assert out == ("\n* Read 'a.txt' \n\n* Read 'a.txt' L1-4 (done)\n\n")
assert "\x1b[" not in out
def test_clear_streamed_lines_clears_exactly_the_given_rows(capsys: pytest.CaptureFixture[str]) -> None:
@@ -1012,12 +1134,6 @@ def test_clear_streamed_lines_clears_exactly_the_given_rows(capsys: pytest.Captu
assert capsys.readouterr().out == "\r\x1b[2K\x1b[1A\r\x1b[2K"
def test_clear_streamed_lines_resume_above_steps_back_one_row(capsys: pytest.CaptureFixture[str]) -> None:
"""``resume_above`` clears the cursor row only, then steps above it."""
_clear_streamed_lines(1, resume_above=True)
assert capsys.readouterr().out == "\r\x1b[2K\x1b[1A"
def test_clear_streamed_lines_ignores_non_positive_counts(capsys: pytest.CaptureFixture[str]) -> None:
_clear_streamed_lines(0)
_clear_streamed_lines(-1)
+4 -1
View File
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
import pytest
from plyngent.cli.display import close_open_pretty_line
from plyngent.cli.limits import (
auto_continue_enabled,
format_tool_confirm_box,
@@ -14,7 +15,7 @@ from plyngent.cli.limits import (
reset_auto_continue_turn,
set_auto_continue_default,
)
from plyngent.prompting import NonInteractiveBackend, get_prompt_backend, temporary_backend
from plyngent.prompting import NonInteractiveBackend, get_prompt_backend, get_prompt_output_hook, temporary_backend
from plyngent.tools.process.pty_session import PtyManager
from tests.test_prompting import ScriptedBackend
@@ -121,6 +122,8 @@ def test_format_tool_confirm_box_multiline() -> None:
def test_install_cli_limit_hooks() -> None:
install_cli_limit_hooks()
assert callable(getattr(PtyManager, "_limit_continue", None))
# A prompt raised mid-call must end the open pretty tool line first.
assert get_prompt_output_hook() is close_open_pretty_line
PtyManager.set_limit_continue_hook(None)
# Backend remains usable after install.
assert get_prompt_backend().is_interactive() or True
+69
View File
@@ -0,0 +1,69 @@
"""Wiring of resolved thinking settings into the CLI session state."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
import tomlkit
from plyngent.cli.state import ReplState
from plyngent.config.models import AnthropicProvider, DatabaseConfig, ModelConfig
from plyngent.config.store import ConfigStore
from plyngent.lmproto.anthropic import AnthropicClient
from plyngent.memory import MemoryStore
if TYPE_CHECKING:
from pathlib import Path
_DOCUMENT = """
[agent]
reasoning_effort = "low"
thinking_budget_tokens = 2000
"""
async def _state(tmp_path: Path, provider: AnthropicProvider, model: str) -> tuple[ReplState, MemoryStore]:
memory = await MemoryStore.open(DatabaseConfig())
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.parse(_DOCUMENT))
config.providers = {"local": provider}
state = ReplState(
config=config,
memory=memory,
workspace=tmp_path,
provider_name="local",
provider=provider,
model=model,
tools_enabled=False,
)
await state.new_session("t")
return state, memory
@pytest.mark.asyncio
async def test_agent_and_client_get_resolved_thinking_settings(tmp_path: Path) -> None:
provider = AnthropicProvider(
access_key_or_token="sk-test",
reasoning_effort="high",
models={"claude-test": ModelConfig(reasoning_effort="max")},
)
state, memory = await _state(tmp_path, provider, "claude-test")
try:
# Model layer wins over the provider layer, and both over ``[agent]``.
assert state.agent.reasoning_effort == "xhigh"
assert isinstance(state.client, AnthropicClient)
assert state.client.thinking_budget_tokens == 2000
finally:
await memory.close()
@pytest.mark.asyncio
async def test_provider_layer_wins_when_model_has_no_override(tmp_path: Path) -> None:
provider = AnthropicProvider(access_key_or_token="sk-test", thinking_budget_tokens=9000)
state, memory = await _state(tmp_path, provider, "claude-test")
try:
assert state.agent.reasoning_effort == "low"
assert isinstance(state.client, AnthropicClient)
assert state.client.thinking_budget_tokens == 9000
finally:
await memory.close()
+2
View File
@@ -29,6 +29,8 @@ def test_agent_section_defaults(tmp_path: Path) -> None:
assert store.agent_config.allow_paths == {}
assert store.agent_config.auto_continue_limits is False
assert store.agent_config.max_context_tokens == 200_000
assert store.agent_config.reasoning_effort == ""
assert store.agent_config.thinking_budget_tokens == 0
assert store.plugins_config.enable == []
assert store.plugins_config.disable == []
assert store.networking_config.ssrf_assume_public_cidrs == []
+69 -5
View File
@@ -46,8 +46,8 @@ def test_read_valid_config() -> None:
assert isinstance(providers["test3"], AnthropicProvider)
assert isinstance(providers["foo1"], DeepseekProvider)
# TOML omitted models → DeepSeek defaults.
assert set(providers["foo1"].models) == {"deepseek-v4-flash", "deepseek-v4-pro"}
assert providers["foo1"].models["deepseek-v4-flash"].text is True
assert set(providers["foo1"].models) == {"deepseek-flash", "deepseek-v4-pro"}
assert providers["foo1"].models["deepseek-flash"].text is True
db = config.database
assert db["implementation"] == "sqlite"
assert db["url"] == ":memory:"
@@ -57,7 +57,7 @@ def test_read_valid_config() -> None:
def test_deepseek_default_models_on_construct() -> None:
provider = DeepseekProvider(access_key_or_token="sk-test")
assert set(provider.models) == {"deepseek-v4-flash", "deepseek-v4-pro"}
assert set(provider.models) == {"deepseek-flash", "deepseek-v4-pro"}
def test_openai_default_models_on_construct() -> None:
@@ -179,7 +179,7 @@ preset = "deepseek"
access_key_or_token = "sk-test"
[providers.ds.models]
"deepseek-v4-flash" = { text = true, convention = "responses" }
"deepseek-flash" = { text = true, convention = "responses" }
"deepseek-v4-pro" = { text = true }
""",
encoding="utf-8",
@@ -187,7 +187,7 @@ access_key_or_token = "sk-test"
config = plyngent.config.load(path)
provider = config.providers["ds"]
assert isinstance(provider, DeepseekProvider)
assert provider.models["deepseek-v4-flash"].convention == "responses"
assert provider.models["deepseek-flash"].convention == "responses"
assert provider.models["deepseek-v4-pro"].convention == ""
@@ -299,6 +299,70 @@ def test_read_bad_config() -> None:
assert isinstance(config.bad_providers, Mapping)
def test_reasoning_effort_from_toml(tmp_path: Path) -> None:
path = tmp_path / "reasoning.toml"
_ = path.write_text(
"""
[agent]
reasoning_effort = "low"
thinking_budget_tokens = 4000
[providers.ds]
preset = "deepseek"
access_key_or_token = "sk-test"
reasoning_effort = "high"
[providers.ds.models]
"deepseek-v4-pro" = { text = true, reasoning_effort = "max", thinking_budget_tokens = 32000 }
"deepseek-flash" = { text = true }
""",
encoding="utf-8",
)
config = plyngent.config.load(path)
agent_cfg = config.agent_config
assert agent_cfg.reasoning_effort == "low"
assert agent_cfg.thinking_budget_tokens == 4000
provider = config.providers["ds"]
assert provider.reasoning_effort == "high"
assert provider.thinking_budget_tokens == 0
assert provider.models["deepseek-v4-pro"].reasoning_effort == "max"
assert provider.models["deepseek-v4-pro"].thinking_budget_tokens == 32000
assert provider.models["deepseek-flash"].reasoning_effort == ""
assert provider.models["deepseek-flash"].thinking_budget_tokens == 0
def test_reasoning_effort_defaults_are_unset(tmp_path: Path) -> None:
path = tmp_path / "reasoning-default.toml"
_ = path.write_text(
"""
[providers.ds]
preset = "deepseek"
access_key_or_token = "sk-test"
""",
encoding="utf-8",
)
config = plyngent.config.load(path)
assert config.agent_config.reasoning_effort == ""
assert config.agent_config.thinking_budget_tokens == 0
assert config.providers["ds"].reasoning_effort == ""
def test_reasoning_effort_invalid_is_bad_provider(tmp_path: Path) -> None:
path = tmp_path / "reasoning-bad.toml"
_ = path.write_text(
"""
[providers.ds]
preset = "deepseek"
access_key_or_token = "sk-test"
reasoning_effort = "highest"
""",
encoding="utf-8",
)
config = plyngent.config.load(path)
assert "ds" not in config.providers
assert "ds" in config.bad_providers
def test_provider_with_empty_models_is_recoverable(tmp_path: Path) -> None:
path = tmp_path / "empty-models.toml"
_ = path.write_text(
+100
View File
@@ -0,0 +1,100 @@
"""Tests for thinking-strength resolution across config layers and surfaces."""
from __future__ import annotations
from plyngent.config.models import (
AgentConfig,
AnthropicProvider,
DeepseekProvider,
ModelConfig,
OpenAICompatibleProvider,
OpenAIProvider,
)
from plyngent.config.reasoning import resolve_reasoning
def _compat() -> OpenAICompatibleProvider:
return OpenAICompatibleProvider(access_key_or_token="sk-test", url="https://example.com/v1")
def test_agent_layer_only() -> None:
reasoning = resolve_reasoning(
AgentConfig(reasoning_effort="high", thinking_budget_tokens=9000),
_compat(),
"gpt-4o-mini",
)
assert reasoning.effort == "high"
assert reasoning.thinking_budget_tokens == 9000
def test_provider_overrides_agent_and_model_overrides_provider() -> None:
provider = OpenAICompatibleProvider(
access_key_or_token="sk-test",
url="https://example.com/v1",
reasoning_effort="low",
models={"gpt-4o-mini": ModelConfig(reasoning_effort="xhigh")},
)
agent = AgentConfig(reasoning_effort="minimal")
assert resolve_reasoning(agent, provider, "gpt-4o-mini").effort == "xhigh"
assert resolve_reasoning(agent, provider, "other-model").effort == "low"
assert resolve_reasoning(agent, provider, None).effort == "low"
def test_fields_fall_back_independently() -> None:
provider = OpenAICompatibleProvider(
access_key_or_token="sk-test",
url="https://example.com/v1",
reasoning_effort="high",
thinking_budget_tokens=20000,
models={"gpt-4o-mini": ModelConfig(thinking_budget_tokens=4000)},
)
reasoning = resolve_reasoning(AgentConfig(reasoning_effort="low"), provider, "gpt-4o-mini")
# Model sets the budget only → the effort still comes from the provider.
assert reasoning.effort == "high"
assert reasoning.thinking_budget_tokens == 4000
def test_agent_layer_optional() -> None:
provider = OpenAICompatibleProvider(
access_key_or_token="sk-test",
url="https://example.com/v1",
reasoning_effort="medium",
)
assert resolve_reasoning(None, provider, "gpt-4o-mini").effort == "medium"
def test_unset_layers_stay_unspecified() -> None:
reasoning = resolve_reasoning(None, _compat(), "gpt-4o-mini")
assert reasoning.effort == ""
assert reasoning.thinking_budget_tokens == 0
def test_non_positive_budget_is_ignored() -> None:
provider = OpenAICompatibleProvider(
access_key_or_token="sk-test",
url="https://example.com/v1",
thinking_budget_tokens=0,
)
resolved = resolve_reasoning(AgentConfig(thinking_budget_tokens=-5), provider, "gpt-4o-mini")
assert resolved.thinking_budget_tokens == 0
def test_max_survives_only_on_deepseek_chat() -> None:
chat = DeepseekProvider(access_key_or_token="sk-test", reasoning_effort="max")
assert resolve_reasoning(None, chat, "deepseek-v4-pro").effort == "max"
responses = DeepseekProvider(access_key_or_token="sk-test", convention="responses", reasoning_effort="max")
assert resolve_reasoning(None, responses, "deepseek-v4-pro").effort == "xhigh"
anthropic = AnthropicProvider(access_key_or_token="sk-test", reasoning_effort="max")
assert resolve_reasoning(None, anthropic, "claude-test").effort == "xhigh"
openai = OpenAIProvider(access_key_or_token="sk-test", reasoning_effort="max")
assert resolve_reasoning(None, openai, "gpt-5.4").effort == "xhigh"
def test_effort_levels_pass_through_unchanged() -> None:
provider = _compat()
for effort in ("none", "minimal", "low", "medium", "high", "xhigh"):
resolved = resolve_reasoning(AgentConfig(reasoning_effort=effort), provider, "gpt-4o-mini")
assert resolved.effort == effort
+66
View File
@@ -0,0 +1,66 @@
"""The routing decisions around a provider's API surface."""
from __future__ import annotations
from dataclasses import replace
from plyngent.config.models import DeepseekProvider
from plyngent.config.routing import (
EffectiveProvider,
deepseek_search_base_url,
local_web_search,
resolve_effective_provider,
wants_web_search,
)
SEARCH_TOOLS: list[dict[str, object]] = [{"type": "web_search"}]
def _effective(
*,
convention: str = "",
url: str = "",
tools: list[dict[str, object]] | None = None,
sources: list[str] | None = None,
) -> EffectiveProvider:
provider = DeepseekProvider(access_key_or_token="sk-test", convention=convention, url=url)
effective = resolve_effective_provider(provider, model="deepseek-flash")
if tools is not None:
effective = replace(effective, provider_tools=tools)
if sources is not None:
effective = replace(effective, search_sources=sources)
return effective
def test_a_deepseek_provider_asks_for_a_search_tool_like_openai_does() -> None:
# ``provider_tools`` defaults to web_search for both presets; ``[]`` disables.
assert wants_web_search(_effective()) is True
assert wants_web_search(_effective(tools=[])) is False
def test_chat_and_responses_conventions_use_the_local_tool() -> None:
# Only the Anthropic surface hosts a search; the others ignore a hosted tool,
# so the default web_search becomes plyngent's own tool there.
assert local_web_search(_effective(convention="")) is True
assert local_web_search(_effective(convention="responses")) is True
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_without_search_sources_there_is_no_local_tool() -> None:
# ``search_sources = []`` leaves the tool out even though provider_tools ask
# 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"]
def test_search_base_url_prefers_an_anthropic_endpoint() -> None:
assert deepseek_search_base_url(_effective(convention="responses")) == "https://api.deepseek.com/anthropic"
assert (
deepseek_search_base_url(_effective(convention="responses", url="https://gateway.example/anthropic"))
== "https://gateway.example/anthropic"
)
+82
View File
@@ -11,8 +11,10 @@ from plyngent.lmproto.anthropic.model import (
AnthropicMessagesParam,
AnthropicMessageStop,
AnthropicResponseText,
AnthropicThinkingConfig,
AnthropicUsage,
AnthropicUserMessage,
AnthropicWebSearchToolResultContent,
)
@@ -44,6 +46,86 @@ def test_messages_param_encode_omits_defaults() -> None:
@pytest.mark.asyncio
async def test_client_decodes_a_server_side_search_response(monkeypatch: pytest.MonkeyPatch) -> None:
"""DeepSeek's Anthropic compat answers a search turn with server tool blocks.
A live turn reads ``thinking, server_tool_use, web_search_tool_result, text``
(the results carry an ``encrypted_content`` the API demands back unchanged);
the response must decode, even though none of those blocks is a local call.
"""
body = {
"id": "msg_search",
"type": "message",
"role": "assistant",
"model": "deepseek-flash",
"stop_reason": "end_turn",
"content": [
{"type": "thinking", "thinking": "Let me search.", "signature": "b032a4ad-87fe-42ce-801d-e33dd743d8b9"},
{
"type": "server_tool_use",
"id": "call_00_joE6PEEC1otU8pvSe4zY3017",
"name": "web_search",
"input": {"query": "DeepSeek latest news"},
"caller": {"type": "direct"},
},
{
"type": "web_search_tool_result",
"tool_use_id": "call_00_joE6PEEC1otU8pvSe4zY3017",
"content": [
{
"type": "web_search_result",
"title": "Change Log | DeepSeek API Docs",
"url": "https://api-docs.deepseek.com/updates/",
"encrypted_content": "sd918tZXSvVp6j7ermvF4cl/lspbqHzths9SMQw6ziEw",
}
],
},
{"type": "text", "text": "DeepSeek shipped a new model."},
],
"usage": {"input_tokens": 16592, "output_tokens": 1419, "server_tool_use": {"web_search_requests": 4}},
}
client = AnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://example/v1"))
class _Resp:
status_code = 200
content = msgspec.json.encode(body)
async def fake_post(path: str, **kwargs: object) -> _Resp:
return _Resp()
monkeypatch.setattr(client.session, "post", fake_post)
result = await client.messages(AnthropicMessagesParam(model="deepseek-flash", messages=[]))
kinds = [block.__struct_config__.tag for block in result.content]
assert kinds == ["thinking", "server_tool_use", "web_search_tool_result", "text"]
search = result.content[2]
assert isinstance(search, AnthropicWebSearchToolResultContent)
assert search.tool_use_id == "call_00_joE6PEEC1otU8pvSe4zY3017"
assert search.content[0]["encrypted_content"].startswith("sd918tZXSvVp")
assert result.content[3].text == "DeepSeek shipped a new model."
def test_messages_param_encodes_server_tool_dicts() -> None:
"""A hosted tool goes on the wire as the server-tool dict the surface wants."""
param = AnthropicMessagesParam(
model="deepseek-flash",
messages=[AnthropicUserMessage(content="hi")],
tools=[{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}],
)
data = msgspec.json.decode(msgspec.json.encode(param))
assert data["tools"] == [{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}]
def test_messages_param_keeps_the_thinking_type_on_the_wire() -> None:
"""``omit_defaults`` must not drop the block's ``type`` discriminator."""
param = AnthropicMessagesParam(
model="deepseek-flash",
messages=[AnthropicUserMessage(content="hi")],
thinking=AnthropicThinkingConfig(type="enabled", budget_tokens=16384),
)
data = msgspec.json.decode(msgspec.json.encode(param))
assert data["thinking"] == {"type": "enabled", "budget_tokens": 16384}
async def test_client_messages_create(monkeypatch: pytest.MonkeyPatch) -> None:
client = AnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://example/v1"))
@@ -17,7 +17,7 @@ def _sample_message_body() -> bytes:
return msgspec.json.encode(
AnthropicMessageResponse(
id="msg_1",
model="deepseek-v4-flash",
model="deepseek-flash",
content=[AnthropicResponseText(text="hello world")],
stop_reason="end_turn",
usage=AnthropicUsage(input_tokens=9, output_tokens=3),
@@ -52,7 +52,7 @@ async def test_client_messages_create(monkeypatch: pytest.MonkeyPatch) -> None:
return _Resp()
monkeypatch.setattr(client.session, "post", fake_post)
result = await client.messages(AnthropicMessagesParam(model="deepseek-v4-flash", messages=[]))
result = await client.messages(AnthropicMessagesParam(model="deepseek-flash", messages=[]))
assert isinstance(result, AnthropicMessageResponse)
assert result.content[0].text == "hello world"
assert result.usage.output_tokens == 3
@@ -78,7 +78,7 @@ async def test_client_messages_stream(monkeypatch: pytest.MonkeyPatch) -> None:
return _Resp()
monkeypatch.setattr(client.session, "post", fake_post)
stream = await client.messages(AnthropicMessagesParam(model="deepseek-v4-flash", messages=[]), stream=True)
stream = await client.messages(AnthropicMessagesParam(model="deepseek-flash", messages=[]), stream=True)
events = [event async for event in stream]
assert len(events) == 2
@@ -0,0 +1,115 @@
"""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")
+159
View File
@@ -0,0 +1,159 @@
"""Tests for the DeepSeek chat-completions client's wire quirks.
``thinking`` (an explicit flag the API wants) and the ``reasoning_content``
field a tool-call message must carry back in thinking mode.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any, cast
import msgspec
import pytest
from msgspec import UNSET
from plyngent.lmproto.deepseek import DeepseekOpenAIClient
from plyngent.lmproto.openai_compatible.config import OpenAIConfig
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
AssistantFunctionTool,
AssistantFunctionToolCall,
ChatCompletionsParam,
ToolChatMessage,
UserChatMessage,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, ReasoningEffort
from plyngent.typedef import Unset
async def _posted_body(
monkeypatch: pytest.MonkeyPatch,
effort: ReasoningEffort | Unset = UNSET,
*,
messages: list[AnyChatMessage] | None = None,
) -> dict[str, Any]:
"""Run one streamed request and return the JSON body the client posted."""
client = DeepseekOpenAIClient(OpenAIConfig(access_key_or_token="sk-test", base_url="https://api.deepseek.com"))
captured: dict[str, Any] = {}
class _Resp:
status_code = 200
async def iter_lines(self) -> AsyncIterator[bytes]:
yield b"data: [DONE]"
def close(self) -> None:
return None
async def fake_post(path: str, **kwargs: object) -> _Resp:
assert path == "/chat/completions"
captured["data"] = kwargs.get("data")
return _Resp()
monkeypatch.setattr(client.session, "post", fake_post)
param = ChatCompletionsParam(
model="deepseek-v4-pro",
messages=messages if messages is not None else [UserChatMessage(content="hi")],
reasoning_effort=effort,
)
# Production dispatches the base param through the OpenAI-compatible surface
# (not the DeepSeek-specific annotation), so mirror that here.
cc: Any = client
stream = cast("AsyncIterator[Any]", await cc.chat_completions(param, stream=True))
async for _chunk in stream:
pass
data = captured["data"]
assert isinstance(data, bytes)
return json.loads(data)
@pytest.mark.asyncio
async def test_thinking_enabled_without_configured_effort(monkeypatch: pytest.MonkeyPatch) -> None:
body = await _posted_body(monkeypatch)
assert body["thinking"] == {"type": "enabled"}
assert "reasoning_effort" not in body
@pytest.mark.asyncio
async def test_thinking_enabled_with_effort(monkeypatch: pytest.MonkeyPatch) -> None:
body = await _posted_body(monkeypatch, "high")
assert body["thinking"] == {"type": "enabled"}
assert body["reasoning_effort"] == "high"
@pytest.mark.asyncio
async def test_thinking_disabled_for_none_effort(monkeypatch: pytest.MonkeyPatch) -> None:
body = await _posted_body(monkeypatch, "none")
assert body["thinking"] == {"type": "disabled"}
assert body["reasoning_effort"] == "none"
def _kept_call() -> AssistantChatMessage:
"""The pairing carrier ``rollback_tail`` keeps for a call that ran."""
return AssistantChatMessage(
content=None,
tool_calls=[
AssistantFunctionToolCall(
id="call_1",
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
)
],
)
@pytest.mark.asyncio
async def test_tool_call_without_recorded_reasoning_gets_an_empty_field(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The API refuses a call whose ``reasoning_content`` never came back.
A rolled-back turn keeps its executed calls without one, and a synthetic
``todo_list`` nag is forged the same way; the field only has to be present,
so an empty string satisfies the check.
"""
body = await _posted_body(
monkeypatch,
messages=[
UserChatMessage(content="read it"),
_kept_call(),
ToolChatMessage(content="file body", tool_call_id="call_1"),
],
)
assistant = body["messages"][1]
assert assistant["reasoning_content"] == ""
assert assistant["tool_calls"][0]["id"] == "call_1"
@pytest.mark.asyncio
async def test_recorded_reasoning_content_is_left_alone(monkeypatch: pytest.MonkeyPatch) -> None:
call = _kept_call()
messages: list[AnyChatMessage] = [
UserChatMessage(content="read it"),
msgspec.structs.replace(call, reasoning_content="Open the file first."),
ToolChatMessage(content="file body", tool_call_id="call_1"),
]
body = await _posted_body(monkeypatch, messages=messages)
assert body["messages"][1]["reasoning_content"] == "Open the file first."
@pytest.mark.asyncio
async def test_answer_without_recorded_reasoning_gets_an_empty_field(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A round that called nothing needs the field too.
The rule is per round, not per call: the API answers "the
``reasoning_content`` … must be passed back" for an answer round that never
reasoned as well, and an empty string satisfies it.
"""
body = await _posted_body(
monkeypatch,
messages=[UserChatMessage(content="hi"), AssistantChatMessage(content="all done")],
)
assert body["messages"][1]["reasoning_content"] == ""
assert body["messages"][1]["content"] == "all done"
@@ -9,7 +9,7 @@ from plyngent.lmproto.openai_compatible.config import OpenAIConfig
def _sample_response_body() -> bytes:
return (
b'{"id":"resp_1","object":"response","created_at":1,"model":"deepseek-v4-flash",'
b'{"id":"resp_1","object":"response","created_at":1,"model":"deepseek-flash",'
b'"status":"completed","output":['
b'{"id":"msg_1","type":"message","role":"assistant","status":"completed",'
b'"content":[{"type":"output_text","text":"hello from deepseek","annotations":[]}]}'
@@ -40,7 +40,7 @@ async def test_client_responses_create(monkeypatch: pytest.MonkeyPatch) -> None:
return _Resp()
monkeypatch.setattr(client.session, "post", fake_post)
result = await client.responses(ResponsesCreateParam(model="deepseek-v4-flash", input="hi"))
result = await client.responses(ResponsesCreateParam(model="deepseek-flash", input="hi"))
assert isinstance(result, Response)
assert response_output_text(result) == "hello from deepseek"
@@ -62,7 +62,7 @@ async def test_client_responses_stream_stops_at_terminal(monkeypatch: pytest.Mon
yield (
b'data: {"type":"response.completed","sequence_number":2,'
b'"response":{"id":"resp_1","object":"response","created_at":1,'
b'"model":"deepseek-v4-flash","status":"completed","output":[]}}'
b'"model":"deepseek-flash","status":"completed","output":[]}}'
)
# Anything after the terminal event must not be consumed.
yield b'data: {"type":"response.output_text.delta","delta":"ignored"}'
@@ -77,7 +77,7 @@ async def test_client_responses_stream_stops_at_terminal(monkeypatch: pytest.Mon
monkeypatch.setattr(client.session, "post", fake_post)
stream = await client.responses(
ResponsesCreateParam(model="deepseek-v4-flash", input="hi"),
ResponsesCreateParam(model="deepseek-flash", input="hi"),
stream=True,
)
events = [event async for event in stream]
+2 -2
View File
@@ -169,12 +169,12 @@ async def test_session_llm_remembered(store: MemoryStore) -> None:
session = await store.create_session(
name="llm",
provider_name="deepseek",
model="deepseek-v4-flash",
model="deepseek-flash",
)
row = await store.get_session(session.sid)
assert row is not None
assert row.provider_name == "deepseek"
assert row.model == "deepseek-v4-flash"
assert row.model == "deepseek-flash"
updated = await store.update_session_llm(session.sid, model="deepseek-v4-pro")
assert updated.provider_name == "deepseek"
assert updated.model == "deepseek-v4-pro"
+22
View File
@@ -19,11 +19,14 @@ from plyngent.prompting import (
configure_prompting,
confirm,
form,
get_prompt_backend,
get_prompt_output_hook,
prompt_cancelled,
read_line_with_timeout,
reset_prompting,
run_cancellable_prompt_async,
run_prompt_async,
set_prompt_output_hook,
temporary_backend,
)
@@ -257,3 +260,22 @@ def test_non_interactive_confirm_uses_default() -> None:
with temporary_backend(NonInteractiveBackend()):
assert confirm("ok?", default=False) is False
assert confirm("ok?", default=True) is True
def test_prompt_output_hook_runs_before_chrome(capsys: pytest.CaptureFixture[str]) -> None:
"""A registered hook runs before echo/secho write; clearing it removes it.
The CLI registers one so an open pretty tool line ends before a prompt box.
"""
calls: list[str] = []
assert get_prompt_output_hook() is None
set_prompt_output_hook(lambda: calls.append("break"))
try:
backend = get_prompt_backend()
backend.echo("box")
backend.secho("question", fg="yellow")
finally:
set_prompt_output_hook(None)
assert calls == ["break", "break"]
assert get_prompt_output_hook() is None
assert "box" in capsys.readouterr().out
+28 -5
View File
@@ -60,7 +60,7 @@ def test_deepseek_openai_convention() -> None:
client = create_client(provider)
assert isinstance(client, DeepseekOpenAIClient)
assert provider_to_openai_config(provider).base_url == "https://api.deepseek.com"
assert "deepseek-v4-flash" in provider.models
assert "deepseek-flash" in provider.models
assert "deepseek-v4-pro" in provider.models
@@ -129,13 +129,13 @@ def test_deepseek_model_level_anthropic_convention() -> None:
access_key_or_token="sk-test",
models={
"claude-proxy": ModelConfig(convention="anthropic"),
"deepseek-v4-flash": ModelConfig(),
"deepseek-flash": ModelConfig(),
},
)
proxy = create_client(provider, model="claude-proxy")
assert isinstance(proxy, DeepseekAnthropicClient)
assert proxy.session.base_url == "https://api.deepseek.com/anthropic"
flash = create_client(provider, model="deepseek-v4-flash")
flash = create_client(provider, model="deepseek-flash")
assert isinstance(flash, DeepseekOpenAIClient)
@@ -145,11 +145,11 @@ def test_deepseek_model_level_convention_override() -> None:
provider = DeepseekProvider(
access_key_or_token="sk-test",
models={
"deepseek-v4-flash": ModelConfig(convention="responses"),
"deepseek-flash": ModelConfig(convention="responses"),
"deepseek-v4-pro": ModelConfig(),
},
)
flash = create_client(provider, model="deepseek-v4-flash")
flash = create_client(provider, model="deepseek-flash")
assert isinstance(flash, DeepseekResponsesClient)
assert flash.kind == "responses"
pro = create_client(provider, model="deepseek-v4-pro")
@@ -167,6 +167,29 @@ def test_anthropic_client_created() -> None:
assert client.session.timeout == 30
def test_anthropic_client_thinking_budget_from_config() -> None:
from plyngent.config.reasoning import ReasoningConfig
from plyngent.lmproto.anthropic import AnthropicClient
provider = AnthropicProvider(
access_key_or_token="sk-test",
thinking_budget_tokens=8000,
models={"claude-test": ModelConfig(thinking_budget_tokens=16000)},
)
default_client = create_client(provider, model="claude-test")
assert isinstance(default_client, AnthropicClient)
assert default_client.thinking_budget_tokens == 16000
# An explicit (resolved) reasoning spec wins over the provider/model layers.
client = create_client(
provider,
model="claude-test",
reasoning=ReasoningConfig(effort="high", thinking_budget_tokens=4000),
)
assert isinstance(client, AnthropicClient)
assert client.thinking_budget_tokens == 4000
def test_model_level_preset_url_overrides_parent() -> None:
provider = OpenAICompatibleProvider(
access_key_or_token="sk-test",
+45
View File
@@ -0,0 +1,45 @@
"""Bing's RSS answers as a search source (parsing only — no network)."""
from __future__ import annotations
import pytest
from plyngent.lmproto.deepseek.anthropic.search import SearchHit
from plyngent.tools.net.bing import parse_bing_rss
DOCUMENT = """<?xml version="1.0" encoding="utf-8" ?>
<rss version="2.0"><channel>
<title>必应:DeepSeek Harness</title>
<item>
<title>DeepSeek Harness v0.2</title>
<link>https://pandaily.com/deepseek-harness-v0-2</link>
<description>Desktop installers and a plugin manager.</description>
<pubDate>Sat, 03 Oct 2026 12:06:00 GMT</pubDate>
</item>
<item>
<title>No link here</title>
<description>Dropped: a hit without a URL is nothing to read.</description>
</item>
<item>
<title> Spaced title </title>
<link> https://example.com/spaced </link>
</item>
</channel></rss>
"""
def test_parse_bing_rss_maps_items_to_hits() -> None:
hits = parse_bing_rss(DOCUMENT)
assert hits == [
SearchHit(
title="DeepSeek Harness v0.2",
url="https://pandaily.com/deepseek-harness-v0-2",
snippet="Desktop installers and a plugin manager.",
),
SearchHit(title="Spaced title", url="https://example.com/spaced", snippet=""),
]
def test_parse_bing_rss_rejects_a_non_rss_body() -> None:
with pytest.raises(RuntimeError, match="unparseable XML"):
_ = parse_bing_rss("<!doctype html><html><body>rate limited</body></html>")
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
from plyngent.tools.command_scan import basename, is_interpreter, unwrap_command
def test_basename() -> None:
assert basename("/usr/bin/python3.12") == "python3.12"
assert basename("C:\\Tools\\RM.EXE") == "rm"
assert basename("pdm") == "pdm"
def test_unwrap_plain_command() -> None:
scan = unwrap_command(["grep", "-c", "needle", "f.txt"])
assert scan.wrappers == ()
assert scan.offset == 0
assert scan.base == "grep"
assert scan.command == ("grep", "-c", "needle", "f.txt")
assert scan.self_review == ()
def test_unwrap_env_assignment_and_options() -> None:
scan = unwrap_command(["env", "FOO=1", "nice", "-n", "10", "python", "-c", "print(1)"])
assert scan.wrappers == ("env", "nice")
assert scan.offset == 5
assert scan.base == "python"
assert scan.command == ("python", "-c", "print(1)")
def test_unwrap_privileged_wrapper() -> None:
scan = unwrap_command(["sudo", "-u", "root", "--", "python", "script.py"])
assert scan.wrappers == ("sudo",)
assert scan.base == "python"
assert scan.command == ("python", "script.py")
def test_unwrap_wrapper_duration_of_its_own() -> None:
scan = unwrap_command(["timeout", "30s", "bash", "-c", "ls"])
assert scan.wrappers == ("timeout",)
assert scan.base == "bash"
scan = unwrap_command(["timeout", "5"])
assert scan.wrappers == ()
assert scan.base == "timeout"
def test_unwrap_run_launcher_skips_its_options() -> None:
scan = unwrap_command(["uv", "run", "--with", "rich", "python", "-c", "print(1)"])
assert scan.wrappers == ("uv",)
assert scan.base == "python"
assert scan.command == ("python", "-c", "print(1)")
def test_unwrap_nested_wrapper_and_launcher() -> None:
scan = unwrap_command(["nohup", "pdm", "run", "python", "script.py"])
assert scan.wrappers == ("nohup", "pdm")
assert scan.offset == 3
assert scan.base == "python"
assert scan.self_review == ("nohup",)
def test_unwrap_stops_when_no_program_follows() -> None:
for argv in (["env"], ["timeout", "5"], ["pdm", "run"], ["nohup"]):
scan = unwrap_command(argv)
assert scan.wrappers == (), argv
assert scan.offset == 0, argv
assert scan.base == argv[0], argv
def test_unwrap_value_free_flags_do_not_swallow_the_program() -> None:
"""Only options that consume the next token may be listed as value-taking.
``exec -c`` (empty environment) and ``exec -l`` (login), ``command
-p|-v|-V``, ``pkexec --disable-internal-agent`` and ``su -p`` are flags: a
table entry for them ate the program behind them, so ``exec -c rm -rf /``
hid ``rm`` from the denylist while ``exec -c git -c … init`` resolved to
``init``.
"""
cases = [
(["exec", "-c", "rm", "-rf", "/"], "rm"),
(["exec", "-c", "git", "-c", "init.defaultBranch=main", "init"], "git"),
(["exec", "-l", "python", "script.py"], "python"),
(["command", "-v", "rm"], "rm"),
(["command", "-p", "rm", "-rf", "/"], "rm"),
(["pkexec", "--disable-internal-agent", "rm", "-rf", "/"], "rm"),
(["su", "-p", "python", "script.py"], "python"),
# Options that do take a value still consume it.
(["exec", "-a", "name", "rm", "-rf", "/"], "rm"),
(["xargs", "-e", "rm", "-rf", "/"], "rm"),
]
for argv, base in cases:
scan = unwrap_command(argv)
assert scan.base == base, argv
assert scan.command[0] == base, argv
def test_unwrap_collects_command_line_option_values() -> None:
"""A wrapper option that takes a command line is reported, not just skipped.
``env -S "rm -rf /"`` hands over a command the review must see; the value is
code, so it must not be treated as an argument (or as the program).
"""
scan = unwrap_command(["env", "-S", "rm -rf /"])
assert scan.base == "env"
assert [(span.wrapper, span.option, span.text) for span in scan.command_strings] == [("env", "-S", "rm -rf /")]
assert scan.command_strings[0].label == "env -S"
for argv, option, text in (
(["env", "--split-string=rm -rf /"], "--split-string", "rm -rf /"),
(["env", "-Srm -rf /"], "-S", "rm -rf /"),
(["su", "-c", "rm -rf /"], "-c", "rm -rf /"),
(["su", "--session-command=rm -rf /"], "--session-command", "rm -rf /"),
):
spans = unwrap_command(argv).command_strings
assert [(span.option, span.text) for span in spans] == [(option, text)], argv
# Plain wrapper options stay arguments: no command line to review.
assert unwrap_command(["env", "-u", "FOO=1", "rm", "-rf", "/"]).command_strings == ()
assert unwrap_command(["env", "FOO=1", "git", "-c", "x=y", "log"]).command_strings == ()
def test_unwrap_empty_argv() -> None:
scan = unwrap_command([])
assert scan.base == ""
assert scan.command == ()
def test_is_interpreter_ignores_version_suffix() -> None:
assert is_interpreter("python")
assert is_interpreter("python3.12")
assert is_interpreter("node20")
assert is_interpreter("php8.2")
assert is_interpreter("sqlite3")
assert is_interpreter("bash")
assert not is_interpreter("grep")
assert not is_interpreter("od")
assert not is_interpreter("phpmyadmin")
assert not is_interpreter("")
+88 -1
View File
@@ -96,6 +96,93 @@ def test_classify_shell_and_dash_c() -> None:
assert r5 is not None and "-c" in r5
def test_dash_c_means_code_only_for_interpreters() -> None:
# ``-c`` is a plain flag elsewhere: no code to review, no confirm.
assert classify_danger("run_argv", {"argv": ["grep", "-c", "needle", "f.txt"]}) is None
assert classify_danger("run_argv", {"argv": ["od", "-c", "f.bin"]}) is None
assert classify_danger("run_argv", {"argv": ["sort", "-c", "f.txt"]}) is None
def test_interpreter_review_does_not_split_on_dash_c() -> None:
script = classify_danger("run_argv", {"argv": ["python", "script.py"]})
one_liner = classify_danger("run_argv", {"argv": ["python", "-c", "print(1)"]})
bare_shell = classify_danger("open_pty", {"command": ["bash"]})
assert script is not None and "interpreter 'python'" in script
assert one_liner is not None and "interpreter 'python'" in one_liner
assert bare_shell is not None and "interpreter 'bash'" in bare_shell
# Only the ``-c`` form has code to print below the argv line.
assert "command:" not in script
assert "command:" in one_liner
assert "print(1)" in one_liner
def test_versioned_interpreter_is_still_reviewed() -> None:
reason = classify_danger("run_argv", {"argv": ["python3.12", "-c", "import os"]})
assert reason is not None and "interpreter 'python3.12'" in reason
assert "import os" in reason
def test_wrappers_reach_the_interpreter() -> None:
for argv in (
["env", "FOO=1", "python", "-c", "print(1)"],
["sudo", "-u", "root", "python", "-c", "print(1)"],
["timeout", "5", "bash", "-c", "rm -rf /"],
["pdm", "run", "python", "-c", "print(1)"],
["uv", "run", "--with", "rich", "python", "-c", "print(1)"],
):
reason = classify_danger("run_argv", {"argv": argv})
assert reason is not None, argv
assert "interpreter 'python'" in reason or "interpreter 'bash'" in reason
assert "$(command)" in reason
def test_value_free_flags_do_not_hide_the_interpreter() -> None:
"""``exec -c`` takes no value, so the interpreter behind it is still reviewed."""
reason = classify_danger("run_argv", {"argv": ["exec", "-c", "python", "-c", "print(1)"]})
assert reason is not None
assert "interpreter 'python'" in reason
assert "print(1)" in reason
def test_wrapper_command_line_option_is_reviewed() -> None:
"""`env -S` / `su -c` carry a command line, so they are confirmed like `-c`."""
reason = classify_danger("run_argv", {"argv": ["env", "-S", "rm -rf /"]})
assert reason is not None
assert "env -S runs a command line" in reason
assert " env -S:\n rm -rf /" in reason
# Plain wrapper options are not a review trigger.
assert classify_danger("run_argv", {"argv": ["env", "-u", "FOO=1", "git", "status"]}) is None
assert classify_danger("run_argv", {"argv": ["env", "FOO=1", "git", "-c", "x=y", "log"]}) is None
def test_wrapper_command_line_value_needs_no_interpreter() -> None:
"""The wrapper's value is reviewed even when no interpreter is resolved."""
reason = classify_danger("run_argv", {"argv": ["env", "-S", "python -O script.py"]})
assert reason is not None
assert "env -S runs a command line" in reason
assert "python -O script.py" in reason
def test_only_the_interpreter_dash_c_becomes_a_placeholder() -> None:
reason = classify_danger("run_argv", {"argv": ["ionice", "-c", "2", "python", "-c", "print(1)"]})
assert reason is not None
argv_line = next(line for line in reason.splitlines() if line.startswith(" argv:"))
assert "ionice -c 2" in argv_line
assert argv_line.count("$(command)") == 1
assert "print(1)" in reason
def test_detached_runner_is_always_reviewed() -> None:
assert classify_danger("run_argv", {"argv": ["sleep", "5"]}) is None
reason = classify_danger("run_argv", {"argv": ["nohup", "sleep", "5"]})
assert reason is not None and "nohup (detached run)" in reason
nested = classify_danger("run_argv", {"argv": ["nohup", "python", "-c", "print(1)"]})
assert nested is not None
assert "nohup (detached run)" in nested and "interpreter 'python'" in nested
assert "print(1)" in nested
async def test_confirm_deny_with_comment() -> None:
from plyngent.agent.tools import ToolRegistry, tool
from plyngent.tools.danger import classify_danger as danger
@@ -124,7 +211,7 @@ def test_shell_confirm_formats_command_placeholder() -> None:
assert reason is not None
assert "$(command)" in reason
assert "line1" in reason and "line2" in reason and "line3" in reason
argv_line = next(ln for ln in reason.splitlines() if "argv:" in ln)
argv_line = next(ln for ln in reason.splitlines() if ln.startswith(" argv:"))
assert "line1" not in argv_line
lines = reason.splitlines()
idx = lines.index(" command:")
+2 -5
View File
@@ -269,13 +269,10 @@ async def test_fetch_char_truncation_embeds_token(workspace: Path, http_server:
token_str = token_line.split("truncate_token=", 1)[1].rstrip("]")
token = decode_truncate_token(token_str)
assert token is not None
assert token.kind == "http"
# The body is kept in memory, so resuming never repeats the request.
assert token.kind == "memory"
assert token.limit == 1000
assert 0 < token.offset < 1000
# Continuation resumes exactly where the chunk stopped (no gap).
out2 = await call_async(fetch, f"{http_server}/big", max_chars=1000, offset=token.offset)
body2 = out2.split("--- body ---", 1)[-1].strip()
assert body2.startswith("x")
async def test_fetch_offset_skips_body(workspace: Path, http_server: str) -> None:
+31 -9
View File
@@ -84,18 +84,40 @@ async def test_get_truncated_file_missing(workspace: Path) -> None:
assert out.startswith("error:")
async def test_get_truncated_http_passthrough(monkeypatch) -> None:
calls: list[tuple[str, int, int]] = []
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
async def fake_fetch(url: str, *, offset: int, max_chars: int) -> str:
calls.append((url, offset, max_chars))
return f"body-from-{offset}"
fetch_module = importlib.import_module("plyngent.tools.net.fetch")
monkeypatch.setattr(get_truncated_module.fetch, "handler", fake_fetch)
token = encode_truncate_token(TruncateToken(kind="http", location="https://example.com/x", offset=500, limit=400))
async def _must_not_refetch(*_args: object, **_kwargs: object) -> str:
msg = "resuming a fetch body must not re-run the request"
raise AssertionError(msg)
monkeypatch.setattr(fetch_module, "http_fetch", _must_not_refetch)
first = format_fetch_result(
status=200,
final_url="https://example.com/x",
content_type="text/plain",
body_text="x" * 600,
body_bytes=600,
truncated=False,
redirects=0,
method="GET",
security="public",
warnings=[],
body_kind="text",
max_chars=200,
)
token = _extract_token(first)
assert token is not None
parsed = decode_truncate_token(token)
assert parsed is not None
assert parsed.kind == "memory"
out = await call_async(get_truncated, token, max_chars=200)
assert out == "body-from-500"
assert calls == [("https://example.com/x", 500, 400)]
content, _, _ = out.partition("\n[Truncated")
assert content == "x" * len(content)
assert 0 < len(content) <= 200
async def test_get_truncated_memory_chains() -> None:
+117
View File
@@ -0,0 +1,117 @@
"""The local ``web_search`` tool DeepSeek uses where its surface cannot host one."""
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.tools.net.search import (
WEB_SEARCH_TOOL_NAME,
build_web_search_tool,
search_in_order,
)
class _StubBackend:
"""Stands in for a search source (raises, finds nothing, or answers)."""
def __init__(self, *, hits: list[SearchHit] | None = None, error: str | None = None) -> None:
self.hits = hits or []
self.error = error
self.queries: list[str] = []
async def search(self, query: str) -> list[SearchHit]:
self.queries.append(query)
if self.error is not None:
raise RuntimeError(self.error)
return self.hits
def _hits(*pairs: tuple[str, str]) -> list[SearchHit]:
return [SearchHit(title=title, url=url) for title, url in pairs]
def test_tool_definition_shape() -> None:
definition = build_web_search_tool([_StubBackend()])
assert definition.name == WEB_SEARCH_TOOL_NAME
assert definition.tags == ToolTag.LOCAL | ToolTag.READ_ONLY
assert "fetch" in definition.description
parameters = definition.parameters
assert parameters["properties"]["query"] == {"type": "string"}
assert parameters["required"] == ["query"]
@pytest.mark.asyncio
async def test_handler_returns_one_entry_per_hit() -> None:
backend = _StubBackend(hits=_hits(("DeepSeek-V4 preview", "https://api-docs.deepseek.com/x")))
definition = build_web_search_tool([backend])
assert await definition.handler(query="DeepSeek-V4") == "- DeepSeek-V4 preview — https://api-docs.deepseek.com/x"
assert backend.queries == ["DeepSeek-V4"]
@pytest.mark.asyncio
async def test_handler_prints_the_snippet_under_its_hit() -> None:
backend = _StubBackend(hits=[SearchHit(title="T", url="https://example.com/1", snippet="One line of it.")])
definition = build_web_search_tool([backend])
assert await definition.handler(query="q") == "- T — https://example.com/1\n One line of it."
@pytest.mark.asyncio
async def test_the_first_source_that_answers_wins() -> None:
failing = _StubBackend(error="HTTP 503")
empty = _StubBackend()
answering = _StubBackend(hits=_hits(("Found", "https://example.com/found")))
hits, failures = await search_in_order([failing, empty, answering], "q")
assert [hit.url for hit in hits] == ["https://example.com/found"]
assert failures == ["_StubBackend: HTTP 503", "_StubBackend: no results"]
@pytest.mark.asyncio
async def test_handler_caps_the_hits_and_names_an_untitled_page() -> None:
backend = _StubBackend(hits=_hits(*[("", f"https://example.com/{index}") for index in range(5)]))
definition = build_web_search_tool([backend], max_hits=2)
assert (await definition.handler(query="anything")).splitlines() == [
"- (untitled) — https://example.com/0",
"- (untitled) — https://example.com/1",
]
@pytest.mark.asyncio
async def test_handler_reports_every_source_that_failed() -> None:
definition = build_web_search_tool([_StubBackend(error="timeout"), _StubBackend()])
message = await definition.handler(query="nothing matches this")
assert "found nothing" in message
assert "timeout" in message and "no results" in message
@pytest.mark.asyncio
async def test_handler_without_sources_says_so() -> None:
definition = build_web_search_tool([])
assert "no source configured" in await definition.handler(query="q")
def test_deepseek_backend_passes_the_model_through() -> None:
from plyngent.tools.net.search import DeepseekSearchBackend
class _Client:
def __init__(self) -> None:
self.seen: list[tuple[str, str]] = []
async def search(self, query: str, *, model: str, **kwargs: object) -> list[SearchHit]:
self.seen.append((query, model))
return _hits(("T", "https://example.com"))
client: Any = _Client()
backend = DeepseekSearchBackend(client, model="deepseek-flash")
async def run() -> list[SearchHit]:
return list(await backend.search("q"))
import asyncio
hits = asyncio.run(run())
assert [hit.url for hit in hits] == ["https://example.com"]
assert client.seen == [("q", "deepseek-flash")]
+4 -4
View File
@@ -26,7 +26,7 @@ def test_decode_garbage_returns_none() -> None:
def test_decode_rejects_invalid_fields() -> None:
bad = TruncateToken(kind="file", location="x", offset=-1, limit=10)
assert decode_truncate_token(encode_truncate_token(bad)) is None
bad_limit = TruncateToken(kind="http", location="u", offset=0, limit=0)
bad_limit = TruncateToken(kind="file", location="u", offset=0, limit=0)
assert decode_truncate_token(encode_truncate_token(bad_limit)) is None
@@ -95,7 +95,7 @@ def test_truncate_with_token_end_reached_no_token() -> None:
out, token = truncate_with_token(
text,
40,
kind="http",
kind="file",
location="https://example.com/x",
offset=0,
limit=40,
@@ -111,14 +111,14 @@ def test_truncate_with_token_nonzero_start() -> None:
out, token = truncate_with_token(
text,
200,
kind="http",
kind="file",
location="https://example.com/x",
offset=150,
limit=200,
total_len=700,
)
assert token is not None
assert token.kind == "http"
assert token.kind == "file"
content, _, _ = out.partition("\n[Truncated")
assert token.offset == 150 + len(content)
assert "[Truncated (200 chars max.; 350 omitted). truncate_token=" in out
+142
View File
@@ -215,6 +215,148 @@ def test_command_denylist_policy_confirm_timeout_value(workspace: object) -> Non
clear_policy_allowed_commands()
def test_command_denylist_sees_the_program_behind_value_free_flags(workspace: object) -> None:
"""`exec -c` / `command -p` take no value, so the program stays visible."""
del workspace
from plyngent.tools.workspace import clear_policy_allowed_commands, set_policy_confirm_hook
set_policy_confirm_hook(None)
clear_policy_allowed_commands()
set_command_denylist(None)
try:
for argv in (
["exec", "-c", "rm", "-rf", "/"],
["command", "-p", "rm", "-rf", "/"],
["command", "-v", "rm"],
):
with pytest.raises(WorkspaceError, match="basename 'rm' is blocked"):
check_command_allowed(list(argv))
# ``init`` here is a git subcommand, not the denylisted program: the
# command resolves to ``git`` and stays allowed.
check_command_allowed(["exec", "-c", "git", "-c", "init.defaultBranch=main", "init"])
finally:
clear_policy_allowed_commands()
set_command_denylist(None)
def test_command_denylist_reaches_through_wrappers(workspace: object) -> None:
del workspace
from plyngent.tools.workspace import clear_policy_allowed_commands, set_policy_confirm_hook
set_policy_confirm_hook(None)
clear_policy_allowed_commands()
try:
with pytest.raises(WorkspaceError, match="basename 'rm' is blocked"):
check_command_allowed(["env", "FOO=1", "rm", "-rf", "/"])
with pytest.raises(WorkspaceError, match="basename 'rm' is blocked"):
check_command_allowed(["pdm", "run", "rm", "-rf", "/"])
with pytest.raises(WorkspaceError, match="basename 'rm' is blocked"):
check_command_allowed(["timeout", "5", "rm", "-rf", "/"])
# The outermost denylisted program is the one named (``sudo`` here).
with pytest.raises(WorkspaceError, match="basename 'sudo' is blocked"):
check_command_allowed(["sudo", "-u", "root", "rm", "-rf", "/"])
# Wrapped programs that are not denylisted stay allowed.
check_command_allowed(["pdm", "run", "pytest", "-x"])
check_command_allowed(["env", "FOO=1", "echo", "ok"])
finally:
clear_policy_allowed_commands()
def test_command_denylist_matches_lowercased_basename(workspace: object) -> None:
del workspace
from plyngent.tools.workspace import clear_policy_allowed_commands, set_policy_confirm_hook
set_policy_confirm_hook(None)
clear_policy_allowed_commands()
try:
with pytest.raises(WorkspaceError, match="basename 'rm' is blocked"):
check_command_allowed(["RM", "-rf", "/"])
with pytest.raises(WorkspaceError, match="basename 'rm' is blocked"):
check_command_allowed(["C:\\Tools\\rm.exe", "-rf", "/"])
finally:
clear_policy_allowed_commands()
def test_command_denylist_prompts_for_the_wrapped_program(workspace: object) -> None:
del workspace
from plyngent.tools.workspace import clear_policy_allowed_commands, set_policy_confirm_hook
asked: list[str] = []
def hook(basename: str, argv: object, timeout: float) -> bool:
del argv, timeout
asked.append(basename)
return True
set_policy_confirm_hook(hook)
clear_policy_allowed_commands()
try:
check_command_allowed(["pdm", "run", "rm", "-rf", "build"])
assert asked == ["rm"]
# Session grant: the same wrapped program does not re-prompt.
check_command_allowed(["pdm", "run", "rm", "-rf", "dist"])
assert asked == ["rm"]
finally:
set_policy_confirm_hook(None)
clear_policy_allowed_commands()
def test_match_command_denylist_entries() -> None:
from plyngent.tools import match_command_denylist
denylist = frozenset({"rm", "systemctl reboot poweroff", "loginctl kill-session"})
hits = match_command_denylist(["sudo", "rm", "-rf", "/"], denylist)
assert [(hit.program, hit.keyword) for hit in hits] == [("rm", None)]
assert [hit.program for hit in match_command_denylist(["env", "FOO=1", "rm", "x"], denylist)] == ["rm"]
hits = match_command_denylist(["sudo", "sudo", "rm", "x"], frozenset({"sudo", "rm"}))
assert [hit.program for hit in hits] == ["sudo", "rm"]
# Subcommand entries match the program's own arguments, targets included.
hits = match_command_denylist(["systemctl", "reboot"], denylist)
assert [(hit.program, hit.keyword, hit.entry) for hit in hits] == [
("systemctl", "reboot", "systemctl reboot poweroff")
]
assert match_command_denylist(["systemctl", "start", "poweroff.target"], denylist)[0].keyword == "poweroff"
assert match_command_denylist(["pdm", "run", "loginctl", "kill-session"], denylist)[0].program == "loginctl"
# Read-only and unrelated calls stay clean.
assert match_command_denylist(["systemctl", "status", "sshd"], denylist) == []
assert match_command_denylist(["loginctl", "list-sessions"], denylist) == []
def test_command_denylist_power_subcommands(workspace: object) -> None:
del workspace
from plyngent.tools.workspace import clear_policy_allowed_commands, set_policy_confirm_hook
set_policy_confirm_hook(None)
clear_policy_allowed_commands()
try:
with pytest.raises(WorkspaceError, match="basename 'systemctl' is blocked: 'reboot'"):
check_command_allowed(["systemctl", "reboot"])
with pytest.raises(WorkspaceError, match="basename 'systemctl' is blocked: 'reboot'"):
check_command_allowed(["systemctl", "start", "reboot.target"])
with pytest.raises(WorkspaceError, match="basename 'systemctl' is blocked: 'poweroff'"):
check_command_allowed(["systemctl", "--user", "poweroff"])
with pytest.raises(WorkspaceError, match="basename 'systemctl' is blocked: 'emergency'"):
check_command_allowed(["timeout", "3", "systemctl", "emergency"])
with pytest.raises(WorkspaceError, match="basename 'loginctl' is blocked: 'kill-session'"):
check_command_allowed(["loginctl", "kill-session", "3"])
with pytest.raises(WorkspaceError, match="basename 'pm-suspend' is blocked"):
check_command_allowed(["pm-suspend"])
with pytest.raises(WorkspaceError, match="basename 'run0' is blocked"):
check_command_allowed(["run0", "id"])
with pytest.raises(WorkspaceError, match="basename 'systemd-run' is blocked"):
check_command_allowed(["systemd-run", "--user", "echo", "hi"])
# The outermost denylisted program is reported first.
with pytest.raises(WorkspaceError, match="basename 'sudo' is blocked"):
check_command_allowed(["sudo", "systemctl", "reboot"])
# Read-only systemd calls stay allowed.
check_command_allowed(["systemctl", "status", "sshd"])
check_command_allowed(["systemctl", "list-units", "--type=service"])
check_command_allowed(["systemctl", "show", "-p", "CanReboot"])
check_command_allowed(["loginctl", "list-sessions"])
finally:
clear_policy_allowed_commands()
def test_root_required() -> None:
from plyngent.tools.context import InstanceState, bind_instance