46 Commits

Author SHA1 Message Date
NCBM a0aba700e2 release: plyngent 0.10.0 2026-10-17 17:47:03 +08:00
NCBM 729627eee9 docs: spell out the turn and round model
doc/turns-and-rounds.md defines the two nested units the loop is built on: a round is one request→response plus the tool batch it asked for, a turn is one user message through the explicit stop (0..N rounds), and a session is a stored sequence of turns. It records what bounds each unit (max_rounds, truncation continues, retries), which work is round-scoped (request assembly, context reduction, the aggregated soft confirm) and which state resets per turn (read-before-edit, yyy auto-continue, /yolo once, the todo touched flag, usage totals) versus what outlives it (rows, todo stack, grants, reminder bands).

Linked from the architecture doc, with the short version in AGENTS.md.
2026-10-17 17:37:22 +08:00
NCBM 78b764f70e docs: describe the aggregated round confirm 2026-10-17 17:32:44 +08:00
NCBM 0dd70ff31b core/cli: prompt for a round's tool calls once
prompt_confirm_batch_async answers a ToolBatchConfirmHook with one box that numbers every call the round wants to make (continuation lines stay under their entry). The prompt is the plain ``Allow these N tool calls?`` confirm: a deny offers the same one comment for all of them, a non-interactive host denies, and the next round asks again.

The ReplState registry installs the hook next to the per-call one, and format_tool_confirm_box now shares the box drawing with the batch header.
2026-10-17 17:32:44 +08:00
NCBM 86596633e8 core/agent: confirm a round's mutating calls in one prompt
ToolRegistry.prepare_batch_confirm pre-scans a round of tool calls, drops the ones that cannot run (unknown tool, undecodable arguments, missing required argument), and turns two or more soft reasons into a single ToolBatchConfirmHook prompt. The BatchConfirmDecision it returns is applied per call through execute(..., decision=...): allow, or deny with one comment shared by every call of that round.

_execute_tool_calls asks before it gathers, so a batch of moves or deletes no longer stacks one confirm box per call. A single request keeps the old one-call prompt, a host without a batch hook keeps the per-call behaviour, and the answer never outlives the round it was asked in.
2026-10-17 17:32:44 +08:00
NCBM f4c0c45403 docs: describe the compact summarizer tool notice 2026-10-17 17:13:49 +08:00
NCBM f5cff9d90d core/agent: tell the compact summarizer that tools are unavailable 2026-10-17 17:13:42 +08:00
NCBM eef1d3cd29 docs: describe fetch/fetch_local and the DeepSeek search default 2026-10-17 17:08:11 +08:00
NCBM 9742a1ae7a core/config: default the local search to DeepSeek only 2026-10-17 17:06:56 +08:00
NCBM cfe36b024b core/tools+cli: split fetch into remote fetch and fetch_local 2026-10-17 17:06:43 +08:00
NCBM 37e130a7dc docs: describe the DeepSeek Search API backend 2026-10-17 16:52:40 +08:00
NCBM cb19652b2a core/tools+config: search through the DeepSeek Search API 2026-10-17 16:52:31 +08:00
NCBM 7a4205a588 core/lmproto: add the DeepSeek Search API client 2026-10-17 16:52:20 +08:00
NCBM 2054423ebb release: plyngent 0.9.1 2026-10-16 22:31:50 +08:00
NCBM 3c8ca5fcde docs: describe the truncated-round continuation 2026-10-16 22:29:35 +08:00
NCBM 3cd95504c6 core/agent+cli: keep a round cut off at the output limit 2026-10-16 22:29:25 +08:00
NCBM 3c555278d5 docs: describe the disabled-thinking fallback 2026-10-16 22:16:35 +08:00
NCBM f39ec5fb1a core/agent+anthropic: drop the thinking block a model refuses to disable 2026-10-16 22:16:25 +08:00
NCBM 06a5bafab7 docs: describe the adaptive-thinking retry 2026-10-16 22:12:09 +08:00
NCBM 9156df2b02 core/agent+anthropic: retry a refused thinking budget as adaptive thinking 2026-10-16 22:12:00 +08:00
NCBM 3e7acbaf18 release: plyngent 0.9.0 2026-10-16 19:46:54 +08:00
NCBM c55c5be779 deps: refresh the lock (sqlalchemy 2.1.4, platformdirs 4.13.3, tomlkit 0.15.2, pywinpty 3.1.0) 2026-10-16 19:46:44 +08:00
NCBM b0afed612f docs: describe the same-file write ordering of a batch 2026-10-16 19:37:38 +08:00
NCBM 85b0318b77 core/agent+tools: apply a batch's same-file writes in call order 2026-10-16 19:37:29 +08:00
NCBM ab6b5d590c docs: describe the escape error's access mode 2026-10-16 19:29:20 +08:00
NCBM 1c84d63f37 core/tools+config: name the access mode in the workspace escape error 2026-10-16 19:29:11 +08:00
NCBM a4f852eec6 docs: describe the batch command listing 2026-10-16 19:08:24 +08:00
NCBM 6232925c2c cli/display: list a batch's commands above the '* Run batch' row 2026-10-16 19:08:14 +08:00
NCBM 60dd96329d docs: describe the numbered todo listing 2026-10-16 18:53:10 +08:00
NCBM 3bc585ad3d docs: describe the write_file overwrite rule 2026-10-16 18:52:59 +08:00
NCBM 2eb99a079d core/agent+tools+cli: number todos by listing order, not the internal id 2026-10-16 18:52:36 +08:00
NCBM ac33e92d33 core/tools+config: refuse write_file overwrite unless overwrite=true 2026-10-16 18:44:14 +08:00
NCBM 8729330120 core/skills: announce the skill tools in a notice, not a catalog 2026-10-09 21:40:45 +08:00
NCBM e72d2359d7 core/skills: read the filesystem live, drop the cache and reload 2026-10-09 21:40:38 +08:00
NCBM 1f04889449 cli/skills: open the skill directory instead of printing SKILL.md 2026-10-09 21:40:27 +08:00
NCBM 64a8cf7062 docs: drop the /clear references 2026-10-09 21:15:55 +08:00
NCBM 6807055b9f cli/slash: remove the /clear command 2026-10-09 21:15:48 +08:00
NCBM 287b838db9 docs: drop the multi-tenant router plan 2026-10-09 21:15:07 +08:00
NCBM d3f8132b5d core/agent: note the live-probe result for the echoed blocks
A search round goes back with and without a local `tool_use` after its blocks: the endpoint takes both, and the model answers from the results it already holds. With the blocks stripped it calls its own earlier answer fabricated instead. The note sits where the ordering decision lives, so the shape needs no re-probe.
2026-10-09 20:15:38 +08:00
NCBM b0116a67c1 docs: describe the kept provider blocks 2026-10-09 20:09:43 +08:00
NCBM f89c8dec31 lmproto/openai_compatible: drop provider blocks on a chat wire
`provider_blocks` means something only to the surface that produced them (Anthropic `thinking` / server-tool blocks). A session that switched surface would otherwise send those dicts to a chat-completions provider as an assistant-message key it does not know; the request normalization drops the field, on a copy, so the history keeps it.
2026-10-09 20:09:36 +08:00
NCBM 7a54642ecf core/agent: rebuild a streamed round's provider blocks
Streaming is the default path: `thinking` fragments, a `server_tool_use`'s `partial_json` query and a `web_search_tool_result`'s entries arrive as separate SSE events, so the blocks the round has to hand back exist only as fragments. The dispatch rebuilds each one from its start payload (field for field, under the stream's own names) plus the fragments that follow, and passes it to the loop as a chunk; the loop copies it onto the round's message, where a round that kept only those blocks counts as payload rather than an empty completion.

The thinking text streams as reasoning as it arrives — the search query and the signature go back with the round only.
2026-10-09 20:09:29 +08:00
NCBM 2fdbc3ce5d core/agent: keep a Messages round's provider blocks on the assistant message
DeepSeek's Anthropic compat answers a hosted search inside the turn: the response carries the `thinking` that picked the query, the `server_tool_use`, and the `web_search_tool_result`. The response mapping kept text and tool_use only, so the round lost its evidence — and the `thinking` signature, which only the provider can verify, could not go back at all. `AssistantChatMessage.provider_blocks` carries those blocks as raw JSON now (`thinking` text *and* signature, the query, the result entries with an `encrypted_content` the API rejects when trimmed) and the assistant turn hands them back first, ahead of its text and tool_use blocks; `thinking` also lands in `reasoning_content`, so the chain of thought streams as reasoning like every other surface's.

`AnthropicRawContentBlock` names the provider fields a streamed block carries, and `AnthropicServerToolUseContent` keeps the `caller` metadata the wire sends.
2026-10-09 20:09:20 +08:00
NCBM 6c4c8b9328 core/tools: edit_lineno rejections name the read_file call 2026-10-09 19:46:16 +08:00
NCBM 279cbe9389 core/tools: document fetch's 0-based body offset 2026-10-09 19:46:09 +08:00
NCBM 38e2dd58b0 core/config: state the 0-based offsets of skill_read and fetch 2026-10-09 19:46:02 +08:00
91 changed files with 3990 additions and 964 deletions
+29 -25
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project overview
Plyngent is an LLM chat and agent toolkit (Python 3.14+, PDM-managed). Single-user CLI is usable: protocol clients, config, async memory, agent tool loop, workspace tools, skill discovery, and REPL/one-shot chat. Multi-tenant `router/` / web remain Phase H.
Plyngent is an LLM chat and agent toolkit (Python 3.14+, PDM-managed). Single-user CLI is usable: protocol clients, config, async memory, agent tool loop, workspace tools, skill discovery, and REPL/one-shot chat. Multi-tenant web (`web/`) remains Phase H.
## Commands
@@ -28,6 +28,10 @@ GitHub Actions runs `ruff check`, `ruff format --check`, `basedpyright`, then `p
## Architecture
### Conversation units: turn vs round
A **round** is one model round trip: the request, the single assistant message it produced, and the tool batch that message asked for (one `UsageEvent` each). A **turn** is one user message through the explicit stop — the first round whose assistant message asks for no tools — and holds 0..N rounds; `retry` continues the *same* turn instead of opening a new one. `max_rounds` (32 by default) bounds rounds per turn. Turn-scoped state (read-before-edit, `yyy` auto-continue, `/yolo once`, todo-nag "touched" flag, `last_turn_usage`/`last_turn_rounds`) resets on the next user message; session-scoped state (rows, todo stack, grants) does not. Details: [doc/turns-and-rounds.md](doc/turns-and-rounds.md).
### Data modeling: `msgspec.Struct`
All protocol models use `msgspec.Struct` — not dataclasses, not Pydantic. Optional fields use `msgspec.field(default=UNSET)` / `default=UNSET` with type `T | Unset`.
@@ -50,13 +54,13 @@ 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; 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/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. `AnthropicRawContentBlock` names the same fields on the streaming payloads, and an assistant message's `content` accepts the opaque block dicts it echoes back.
- **`anthropic/client.py`** — `AnthropicClient` (`kind="messages"`): `POST /messages` + `GET /models`.
- **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]`.
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`, `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.
@@ -68,7 +72,7 @@ MCP (`runtime/mcp_client.py`): `McpManager` owns one `McpServerConnection` per `
### Skills (`skills/`)
Discovery and parsing, independent of the agent: `build_roots(config, workspace=…, user_config_dir=…, home=…)` resolves `[skills]` into ordered `SkillRoot`s (`config` explicit paths → `plyngent` user dir → `claude` user/project dirs), `SkillStore` scans each root for directories with a `SKILL.md` (a root may itself be one), parses the frontmatter subset (`frontmatter.py`), and caches the records against the directory list and its `SKILL.md` timestamps. `skills` returns the visible records sorted by name (first root wins), `shadowed()` the losing copies, `find(name)` exact-then-case-insensitive, `default_write_root()` our own directory (or the first root), `reload()` forces a rescan; `resolve_inside` / `resolve_skill_file` keep every read and write inside a skill directory (absolute paths and symlink escapes are refused). Records carry `issues` (unusable declared name, no description, unparseable frontmatter) so a repaired third-party skill stays visible instead of being silently fixed.
Discovery and parsing, independent of the agent: `build_roots(config, workspace=…, user_config_dir=…, home=…)` resolves `[skills]` into ordered `SkillRoot`s (`config` explicit paths → `plyngent` user dir → `claude` user/project dirs), `SkillStore` scans each root for directories with a `SKILL.md` (a root may itself be one) and parses the frontmatter subset (`frontmatter.py`) on **every** access — nothing is cached, so an edit or a new skill made outside the chat is simply there. `skills` returns the visible records sorted by name (first root wins), `shadowed()` the losing copies, `find(name)` exact-then-case-insensitive, `default_write_root()` our own directory (or the first root); `resolve_inside` / `resolve_skill_file` keep every read and write inside a skill directory (absolute paths and symlink escapes are refused). Records carry `issues` (unusable declared name, no description, unparseable frontmatter) so a repaired third-party skill stays visible instead of being silently fixed.
### Memory (`memory/`)
@@ -78,35 +82,35 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
- **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 (`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`; 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`).
- **`messages_bridge` / `messages_dispatch`**: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments; `thinking` from the request's `reasoning_effort` (level → token budget, floor 1024, `max_tokens` grown when the budget would leave no room) plus the client's configured `thinking_budget_tokens`; a thinking block the model refuses (from Claude 4.7 on, `thinking.type.enabled` is a 400 asking for adaptive thinking + `output_config.effort`; a model whose thinking is always on refuses `thinking.type.disabled`) gets one retry with the shape it takes — adaptive thinking with the level as the effort, or no block at all — and the refusal is remembered per model on the client, so only its first request pays the round trip (only a refusal that arrives before the stream yields anything is retried; a later failure has already been seen); a server tool (`server_tool_use` + its `web_search_tool_result`) is the surface's own business — it streams `input_json_delta` fragments like a call but nothing local asked for it, so only a `tool_use` block becomes a tool call and nothing is executed; `thinking` and the two search blocks are kept as `AssistantChatMessage.provider_blocks` (the thinking text also lands in `reasoning_content`, so it streams as reasoning) and go back with the round, which is also why a round that kept only those blocks is not an empty completion; a chat-completions request drops the field (`compat.coerce_chat_messages`).
- **Provider-side tools**: `provider_tools` on the OpenAI **and** DeepSeek presets (list of dicts; `[{type="web_search"}]` when omitted; `[]` disables) threaded via `ChatAgent`/`run_chat_loop` into Responses and Messages; never executed by `ToolRegistry`. The Messages surface declares its own server tool instead — a bare `web_search` becomes `{type="web_search_20250305", name="web_search"}` (the search then runs inside the response, and its `encrypted_content` results go back verbatim with the round — `AssistantChatMessage.provider_blocks`) — and a DeepSeek provider on a surface that ignores hosted tools (`config.routing.local_web_search`) gets the local `web_search` tool from `tools/net/search.py` instead.
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name. Before running, `execute` answers a call that omits a required argument with the accepted names (`_missing_required_error`) — the handler's bare `TypeError` never reaches the model, and no confirm prompt fires for a call that cannot run. Shape mistakes inside a *present* argument are the tool's own job (see the `chat` bullet). `@tool(writes=("path",))` names a handler's file arguments: one batch applies calls that name the same file one at a time, in call order — the lock is taken before the confirm prompt, so a pending prompt cannot be overtaken — while different files keep running in parallel.
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`; resolved `reasoning_effort` on every request (see Config). A round the provider cut off at the output limit is kept, not failed: the answer and its thinking blocks stay in `messages`, the round's tool calls are dropped (a half-written call must not run — the model re-issues it), and up to `max_truncation_continues` (2) further rounds carry on from a truncation notice; 0 spends the turn as unfinished instead, for `/retry`.
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `provider_tools`; `retry()` continues incomplete turns (user-only, after committed tools — does not re-run those tools — or after a kept truncated answer) and `pending_retry_text` reads a truncation notice as unfinished work.
- **`/compact`**: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first. The default summarizer system prompt states that tool calls are unavailable (a `compact_system_prompt` override replaces that default wholesale).
- Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **truncated** (`kept_chars`/`continued`), **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
- Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`.
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `[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.
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `allow_paths` (static out-of-workspace pre-allow `path → read|write|exec`; never prompts), `auto_continue_limits` (auto-raise tool/PTY limits without prompting; default false), `reasoning_effort` / `thinking_budget_tokens` (thinking strength; see Config), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
### Tools (`tools/`)
Module-level `@tool` handlers. Call `set_workspace_root()` before use.
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots **or** a directory-access grant (mode-aware: `read` < `write` < `exec`, default required `write`; a file grant matches only that file); path substring denylist (wins over grants); command denylist via `match_command_denylist` (checks every program of the wrapper/`run` chain, resolved by `tools/command_scan.py`; an entry is a program, plus optional argument words that make it dangerous — `systemctl reboot poweroff …` matches `systemctl reboot` and `systemctl start reboot.target`, not `systemctl status`; basenames are lower-cased, so `RM` / `rm.exe` count; defaults cover privilege `sudo`/`su`/`doas`/`pkexec`/`run0`/`systemd-run`, destructive `rm`/`mkfs`/`dd`/`chown`/`mount`/…, and power state `shutdown`/`reboot`/`poweroff`/`halt`/`init`/`telinit`/`kexec`/`pm-*`/`s2*`; CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); escape error 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 names the `request_directory_access` mode the call needs (`call request_directory_access with mode='write'`).
- **`access`** (`tools/access.py`): `request_directory_access` (LOCAL|INSTANCE|SESSION) asks the host hook for out-of-workspace access; grants are one `path → mode` pair in one of three stores — `WorkspacePolicy.config_allow` (static `[agent].allow_paths`), `SessionState.access_grants` (human-approved, persisted on the session row), `WorkspacePolicy.yolo_allow` (`--yes`/`/yolo`, process only). `AccessDecision(mode, persist)` from the hook; denylisted paths can never be granted; cap `MAX_ACCESS_GRANTS`. Not a sandbox: `exec` grants (and any command) reach the whole FS.
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range), `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=...`).
- **`file`**: `read_file` (`with_lineno`, `max_chars` default 32k; results start with a 1-based `L{begin}-{end}` range), `write_file` (refuses to replace an existing file unless `overwrite=true`; that replacement is the one soft-confirmed), `listdir`, `tree` (model picks `format=markdown` default / `flat` paths / `decorated`; VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `regex_files` (Python `re` per line, not a glob; literal file/dir `path`, no glob expansion; skips VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path`, `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 interpreters — bare shells/REPLs, scripts, and `-c` one-liners alike (`-c` is code only for an interpreter, so `grep -c` / `od -c` stay unflagged; argv + code preview) —, detached runners (`nohup`), and wrapper options that carry a command line (`env -S "…"`, `su -c "…"`, whose text is printed under its own `env -S:` label), each resolved past env wrappers and `<launcher> run`; deny may include a user comment for the model. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn).
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for interpreters — bare shells/REPLs, scripts, and `-c` one-liners alike (`-c` is code only for an interpreter, so `grep -c` / `od -c` stay unflagged; argv + code preview) —, detached runners (`nohup`), and wrapper options that carry a command line (`env -S "…"`, `su -c "…"`, whose text is printed under its own `env -S:` label), each resolved past env wrappers and `<launcher> run`; deny may include a user comment for the model. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn). A round of tool calls asks **once**: `ToolRegistry.prepare_batch_confirm` pre-scans it (unknown tools, undecodable arguments, and calls missing a required argument are left out — nobody is asked about a call that cannot run), two or more soft reasons collapse into one `ToolBatchConfirmHook` prompt whose `BatchConfirmDecision` `execute(..., decision=…)` applies to each call (CLI box lists every call: `y` = allow these, `m` = review them one at a time through the per-call prompt, `N` = deny with one comment shared by all), the answer covers that round only (the next one asks again), and a lone request keeps the plain one-call prompt.
- **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems.
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core); `wait` (line prompt with timeout; Enter disturbs). Container args are shape-checked (`tools/chat/shape.py`): a mis-typed string `options`/`fields` (or a non-string `question`) returns an explained error — `options="a, b"` used to iterate characters into a per-character menu. `wait`'s `duration` accepts numeric strings (`"5"` = 5s) and rejects booleans/text/containers.
- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by 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.
- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by the number a listing shows (render order, TOP group first; the stored `id` stays internal); DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work (`[TODO OPEN WORK]`); all-terminal non-empty = hygiene (`[TODO HYGIENE]`); nag channel via `[agent] todo_nag_strategy` (`developer`/`user` = prose nag; `synthetic_tool` = forged `todo_list` call + **real** `stack.render()` result + ToolCall/Result events; `none`).
- **`net` / `fetch_local` and `fetch`**: `fetch_local` is the raw HTTP client (GET/POST/PUT/DELETE) via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (`asyncio` `getaddrinfo`). Private/loopback hosts need instance policy grant (CLI timed confirm; **not** YOLO). Public cleartext HTTP and mutating methods soft-confirm (`YOLO`/`TRUSTABLE`). HTTPS→HTTP redirects blocked unless `allow_http_downgrade`. `user_agent` tool arg (or headers) is never replaced by the default when provided. Caps: body bytes/chars, request body size. `fetch` is the remote read-only counterpart, built by the CLI host only for a DeepSeek provider: `POST /v1/tools/fetch` (`lmproto/deepseek/webtools/`) returns a title and Markdown content, billed per successful request (5 QPS, plus a per-domain limit). It does **not** run the local SSRF check — the request leaves from DeepSeek's network, so local/LAN targets are unreachable there (the endpoint answers 403/404) — and never falls back to `fetch_local` on its own. Its body remainder also lives in the memory store, so `get_truncated` resumes without another charge. Both are `READ_ONLY`; `fetch_local` enforces GET-only in a read-only context.
- **`net` / `web_search`** (`tools/net/search.py`, `tools/net/bing.py`): the search a DeepSeek provider cannot host, as a local tool. `build_web_search_tool(backends)` closes over the sources named by `provider.search_sources` and asks them **in fallback order** (`search_in_order`): `DeepseekSearchBackend` (one `POST /v1/tools/search` through `lmproto/deepseek/webtools/`, no model round — `deepseek_api_base_url` drops the convention's `/anthropic` and any `/v1` before the client appends its path; 408/429/5xx are retried 0.5s/1.0s, 402 and other 4xx are not) and/or `BingSearchBackend` (Bing's RSS results, snippets included, fetched through the same client as `fetch_local`; opt in with `search_sources = ["deepseek", "bing"]` — the default is `["deepseek"]`). Hits are `SearchHit` from `lmproto/search.py` (DeepSeek's `snippet`, else its `summary` cut to 200 chars); the handler returns one `title — url` entry per hit (plus the snippet when the source has one), and a source that raises or finds nothing is skipped and reported only if every source fails. Registered by the CLI host only when `config.routing.local_web_search` says so: a DeepSeek provider with a non-empty `search_sources`, unless the Anthropic convention already hosts the search (`convention = "anthropic"` **and** `provider_tools` ask for `web_search`) — so `provider_tools = []` brings the local tool back on that surface too. `provider_tools` owns the hosted tool, `search_sources` owns this one. Tags LOCAL|READ_ONLY.
- **`mcp`** (`tools/mcp.py`): register one server's advertised tools into the catalog as namespaced `mcp__<server>__<tool>` definitions with `ToolSource(kind="mcp", plugin_id=server)`; tags LOCAL (+ READ_ONLY when the server config sets `read_only`) — never YOLO/TRUSTABLE. `register_mcp_tools` purges prior MCP entries then re-registers every connected server (hosts call it on tool-registry rebuild); connections must already be started by `McpManager`.
- **`skills`** (`tools/skills.py`): discovery, reading, and writing of skill directories (`plyngent.skills` owns roots, the `SKILL.md` frontmatter subset, the 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.
- **`skills`** (`tools/skills.py`): discovery, reading, and writing of skill directories (`plyngent.skills` owns roots, the `SKILL.md` frontmatter subset, the uncached store, and the inside-the-skill path rule). `skill_list` (names, source root, directory, description, shadowed copies, frontmatter notes), `skill_read` (a `SKILL.md` by name, or one bundled file — reuses `read_file` for ranges/line numbers/`truncate_token`), `skill_search` (regex over every visible skill via `regex_files`, hits rebased on `<skill>/<file>`), `skill_create` (new `SKILL.md` + optional `files`), `skill_edit` (replace / `append` / rewrite one file; a rewritten `SKILL.md` is re-parsed and notes a lost description). Reads need no grant (the host publishes the roots as `WorkspacePolicy.static_read`, so `read_file`/`regex_files` see them while writes stay denied); writes go through **`SkillWritePolicy`** on the instance — denylist first, then `SessionState.skill_write_roots` (a `session` answer), then `[skills].allow_write`, then the host's confirm hook (CLI `prompt_skill_write_confirm`: `o`nce/`s`ession/`n`o, timed, default deny, **not** skipped by YOLO); a host with no hook denies. Grants are per **skill directory**; `[skills].enabled = false` drops the tools from the registry (`SKILL_TOOL_NAMES`).
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + skills + net (`fetch_local`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `get_truncated`, `listdir`, `glob_paths`, `regex_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `skill_list`/`skill_read`/`skill_search`, `fetch`, `fetch_local`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch_local` accept GET only.
### Prompting (`prompting.py`)
@@ -117,12 +121,12 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data when url unset; explicit `url = ":memory:"` kept + warn); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--auto-continue` (auto-raise limits), `--stream/--no-stream`, `--quiet`. Root `--log-level`. Enabled `[mcp]` servers start before the REPL (`_start_mcp_manager`, per-server failures warn on stderr) and are closed on exit (`McpManager.aclose`); each server spawns in its own session/process group (`start_new_session` / `CREATE_NEW_PROCESS_GROUP`), so a terminal Ctrl+C does not kill it.
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. `/history` is turn-oriented (`cli/transcript.py`): a turn runs from a user row to the next one, rows carry **display ordinals** (never DB row numbers; restart after `/clear`), the turn's ends (user + final answer) always print in full, `-v` expands every row (one-liners), `-vv` prints full bodies + local rows, `--preview` collapses every row, and `N`/`last [N]` address turns while `--message N` addresses one row. Multiline `"""` … `"""`; `/edit` via `$VISUAL`/`$EDITOR` (blocking only). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack. `/skills` (`list` default) prints the discovered skills with their roots, shadowed copies, and frontmatter notes; `/skills search PATTERN` and `/skills read NAME [--file F]` run the matching tool handlers (the slash path binds the tool context itself); `/skills reload` rescans the roots and rebuilds tools. `/mcp` (`list` default) prints per-server status/tool counts + an `instructions` preview when the server sent usage guidance, + last stderr lines; `/mcp reconnect` re-reads config, restarts every enabled server, and rebuilds the tool registry. `/grants` lists directory-access grants (`revoke <index|all>`; config grants point at the TOML); `/status` includes the grant count and the visible skill count (or `off` when `[skills].enabled` is false).
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. `/history` is turn-oriented (`cli/transcript.py`): a turn runs from a user row to the next one, rows carry **display ordinals** (never DB row numbers; restart with the session), the turn's ends (user + final answer) always print in full, `-v` expands every row (one-liners), `-vv` prints full bodies + local rows, `--preview` collapses every row, and `N`/`last [N]` address turns while `--message N` addresses one row. Multiline `"""` … `"""`; `/edit` via `$VISUAL`/`$EDITOR` (blocking only). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack. `/skills` (`list` default) prints the discovered skills with their roots, shadowed copies, and frontmatter notes; `/skills search PATTERN` runs the matching tool handler (the slash path binds the tool context itself); `/skills open NAME` reveals the skill's directory in the system file manager. `/mcp` (`list` default) prints per-server status/tool counts + an `instructions` preview when the server sent usage guidance, + last stderr lines; `/mcp reconnect` re-reads config, restarts every enabled server, and rebuilds the tool registry. `/grants` lists directory-access grants (`revoke <index|all>`; config grants point at the TOML); `/status` includes the grant count and the visible skill count (or `off` when `[skills].enabled` is false).
- `/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**, 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.
- Host notices (`agent/notices.py`): system-level messages appended as trailing `developer` rows, never system-prompt edits — `Notice.to_message()` for the model, `.summary` for the user via `cli.display.echo_notice` (`--quiet` hides the user line only). Durable hosts persist them (`ChatAgent.push_notice`); a side agent without memory keeps its notice local. `has_notice()` dedupes a session-scoped notice: the `skills` one is pushed once per session (feature enabled + tools on), lists nothing, and makes the model ask with `skill_list` / `skill_search`. Trailing developer messages never end a turn, so `/retry` still sees an interrupted turn behind a notice or checkpoint.
- Failed/cancelled turns: user kept; **committed tool rounds kept**, and a batch cut short keeps the calls that already produced a result (`rollback_tail`; written to the store, so a resume continues from them instead of re-running them) — side effects are not re-run on `/retry`, and only unfinished assistant text is dropped; a round cut off at the output limit is not a failure at all: it is kept under a `truncated` notice (see the `run_chat_loop` bullet), which also keeps the turn unfinished for `/retry`; Ctrl+C cancels the in-flight turn (a session-scoped asyncio SIGINT handler routes it, `cli/interrupt.py` — a stray Ctrl+C while the loop waits never tears the REPL down); TTY confirms/ask prompts run off-loop and register their own SIGINT target, so Ctrl+C there cancels just the poll-based read (`wait` reports `cancelled by user`) or is ignored with a hint for readline prompts (`ask_user_*`, confirms) — never the turn, never the loop; auto-retry waits 5s/10s/15s/20s then +10s (10 attempts), its countdown is Ctrl+C-cancellable, and the budget **resets when an attempt completes a model round** (connection recovered); then `/retry`.
- Pretty tool lines: every builtin tool prints its `* Verb 'target' ` prefix as the call starts and appends the outcome (`L1-4 (done)`, `(exit code 0)`, `(session 3)`) when the result lands, so a blocking tool stays visible while it runs; namespaced MCP tools share one generic line (`* MCP <server>:<tool> (done)`). Plugin tools and custom tool calls keep the `[tool] …` / `[tool ok] …` whole-line style. Interactive TTY only (`interactive_terminal()`): piped/plain output and `--verbose` keep whole lines. A second call of the same round holds its prefix back until the open row takes its own detail (results arrive in call order), so each call still prints one finished row; a prompt box (or other output) printed while the tool runs *ends* the open prefix as its own line and falls back to whole lines for the rest of the batch — the cursor is never moved, so a prefix wider than the terminal cannot be half-erased. A batch (one round's calls) that only touches todo tools prints one `* Todo x<N>: Push, Update` line plus its last result, whose text already carries the final stack, instead of one header and a full list render per call. `run_argv_batch` lists its commands above its row: one `$ command` line per step, a `pipe_out` chain joined into one line with ` | `, each line elided past 80 characters.
- **`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.
@@ -171,7 +175,7 @@ A nested function is counted **twice**: its decision points are added to the enc
| G5 (done) | Master FD non-inheritable; `read_pty`/`close_pty` via `to_thread`; chat exit `close_all`; fork stays on loop/main thread. |
| Phase H | Optional PTY helper process (JSON/Unix socket), or subprocess+PTY with asyncio reaping; sandboxed tools, multi-session isolation. |
- **Phase H**: multi-tenant platform (`router/`, auth, sandboxed tools, web). Optional **out-of-process PTY host** if isolation is required.
- **Phase H**: multi-tenant platform (auth, sandboxed tools, `web/`). Optional **out-of-process PTY host** if isolation is required.
## Commit messages
+21 -14
View File
@@ -162,12 +162,10 @@ max_context_tokens = 200000
# Optional skills (instruction directories). See doc/skills.md.
# [skills]
# enabled = true # false = no skill tools, no prompt catalog
# enabled = true # false = no skill tools
# paths = ["~/team-skills"] # explicit roots, highest priority
# discover = ["plyngent", "claude"] # our user dir + other harnesses' dirs
# include_project_roots = true # also <workspace>/.claude/skills
# inject_catalog = true # fold name + description into the system prompt
# max_catalog_skills = 50
# allow_write = false # true = skip the per-write grant prompt
# Optional plugins (entry-point names); default load none. See doc/plugins.md.
@@ -177,7 +175,7 @@ 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`.
**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); a thinking block the model refuses is retried once with the shape it takes — adaptive thinking with the level itself as `output_config.effort` (from Claude 4.7 on), or no block at all where thinking cannot be turned off — and the refusal is remembered for the session, so `thinking_budget_tokens` is ignored on such a model. `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]
@@ -193,7 +191,7 @@ 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).
**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`). A session-start notice (a host notice, never a system-prompt edit) tells the model that the skill tools exist and how to use them — `skill_list` to see what exists, `skill_read` for a `SKILL.md`, `skill_search` for content — and deliberately lists nothing; `skill_create` / `skill_edit` write one 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).
**MCP servers** (Model Context Protocol over stdio): define servers under `[mcp.servers.<name>]` (`command` + `args`, optional `env`/`cwd`/`timeout`/`read_only`) and their tools become agent tools namespaced `mcp__<server>__<tool>` (LOCAL tags; `read_only = true` also marks them READ_ONLY, eligible for `/btw --tools=read`). Names in `[mcp].disable` stay disconnected. A server may also return optional `instructions` (usage guidance) in its MCP `initialize` response; when tools are on those are folded into the agent system prompt as `MCP server <name> instructions:` blocks (`[agent] mcp_instructions = false` disables), and `/mcp` previews them. In the REPL, `/mcp` lists per-server status and tool counts; `/mcp reconnect` re-reads the config file and restarts every enabled server (adopts newly added ones).
@@ -226,6 +224,14 @@ agent's Anthropic Messages path (tools, streaming, usage). DeepSeek maps
DeepSeek ids pass through as-is. `GET /models` is not documented on that
base, so model selection is config-driven.
On the Chat Completions and Responses conventions DeepSeek ignores a hosted
search tool, so plyngent offers its own `web_search` tool instead. It queries
DeepSeek's Search API (`POST /v1/tools/search`, one request per search, billed
per successful request, 5 QPS account-wide) and can fall back to Bing's RSS
(`search_sources = ["deepseek", "bing"]`) when that fails or finds nothing.
`search_sources` sets the source order (`[]` disables the tool); `provider_tools
= []` on the Anthropic convention switches that surface to the same local tool.
If `[database]` is omitted (or SQLite `url` is unset/empty), chat uses a durable file under the user data dir (e.g. `~/.local/share/plyngent/chat.db` on Linux). Set `url = ":memory:"` for a true in-memory SQLite (CLI warns; no file; useful for tests).
## Chat
@@ -288,7 +294,7 @@ Type `/help` in the REPL for the live list. Common ones:
|---------|---------|
| `/status` | Provider, session, context/usage estimates |
| `/history [N \| last [N]]` | Conversation grouped by turn: default = the last turn (your message + the model's answer); `N` = turn N, `last N` = the last N turns |
| `/history -v` / `-vv` | The turn's ends (your message, the answer) always print in full; `-v` adds every intermediate row (tool calls/results, notices) as a one-liner, `-vv` prints those in full too, and `--preview` collapses every row. `--message N` prints one message in full. Numbers are display ordinals shown by `/history`, never database rows (they restart after `/clear`) |
| `/history -v` / `-vv` | The turn's ends (your message, the answer) always print in full; `-v` adds every intermediate row (tool calls/results, notices) as a one-liner, `-vv` prints those in full too, and `--preview` collapses every row. `--message N` prints one message in full. Numbers are display ordinals shown by `/history`, never database rows (they restart with the session) |
| `/sessions` | Sessions for this workspace |
| `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) |
| `/export [md\|json] [path]` | Transcript from DB (no secrets) |
@@ -296,19 +302,19 @@ Type `/help` in the REPL for the live list. Common ones:
| `/stream` `/verbose` `/markdown` `/tools` `/rounds` | Toggles and limits |
| `/yolo [on\|off\|once]` | Soft destructive confirms: sticky skip, off, or next turn only |
| `/retry` | Re-run incomplete last user turn (after error/cancel) |
| `/retry` | Retry/continue the incomplete last user turn (after error, cancel, or output limit) |
| `/btw [--tools read\|no\|full] [--fresh]` | Side question without changing the main session (read-only tools by default; the model is told the exchange is unsaved and how its tools are scoped) |
| `/provider` `/model` | Switch without restarting |
| `/model --persist` | Save current model id into `plyngent.toml` catalog |
| `/models` | List config + remote `GET /models` (always re-fetches) |
| `/models --persist` | Merge remote catalog into TOML for this provider |
| `/todos` | Todo/task stack: list, push, pop, done, clear |
| `/skills [list\|search\|read\|reload]` | List discovered skills, regex-search them, print a `SKILL.md` (or one bundled file), or rescan the roots |
| `/todos` | Todo/task stack: list (items numbered as listed), push, pop, done, clear |
| `/skills [list\|search\|open]` | List discovered skills, regex-search them, or open one's directory in the system file manager |
| `/grants` | Directory-access grants: list, or `revoke <index\|all>` |
| `/config` | Edit `plyngent.toml` ($VISUAL/$EDITOR or system open); reload after blocking editor |
| `/quit` | Leave the REPL |
User messages are saved immediately. On API error or Ctrl+C, unfinished assistant text is discarded — tool calls that already produced a result stay, so `/retry` does not run them again — but the user message stays so `/retry` works after resume. Interactive auto-retry waits 5s, 10s, 15s, 20s, then +10s each step (10 attempts); an attempt that gets a full model round back **resets that budget**, so a connection that recovers and drops again starts over from 5s.
User messages are saved immediately. On API error or Ctrl+C, unfinished assistant text is discarded — tool calls that already produced a result stay, so `/retry` does not run them again — but the user message stays so `/retry` works after resume. Interactive auto-retry waits 5s, 10s, 15s, 20s, then +10s each step (10 attempts); an attempt that gets a full model round back **resets that budget**, so a connection that recovers and drops again starts over from 5s. A round cut off at the output limit (`max_tokens`) is not an error: what the model had written — thinking blocks included — stays in the transcript, its tool calls are dropped (they are re-issued, not replayed), and a notice tells it where it stopped, so up to 2 further rounds carry on from there; when that budget is spent the turn ends with the partial answer kept and `/retry` continues it. Tool calls of one round run in parallel, but writes to the same file apply one at a time, in call order — a later edit sees the earlier one's result instead of stale text.
Ctrl+C cancels the in-flight turn, and during the auto-retry countdown it cancels the retry. Model-initiated prompts run off the event loop: a `wait` prompt is cancelled by Ctrl+C (the tool reports `cancelled by user` and the turn continues), while `ask_user_*` and confirm prompts ignore it with a one-line hint — answer them to continue. Ctrl+C never exits the REPL (use Ctrl+D or `/quit`), and MCP servers run in their own process group, so it never kills them either.
@@ -321,15 +327,16 @@ Ctrl+C cancels the in-flight turn, and during the auto-retry countdown it cancel
## Tools (when enabled)
Default registry: file ops (including `tree` with a markdown bullet-list default, `flat` paths or classic `decorated` on demand; default noise-dir skips), `run_argv` / `run_argv_batch` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), HTTP `fetch` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), `wait` (line prompt with timeout; Enter disturbs), `request_directory_access` (out-of-workspace access grants), `get_truncated` (resume any truncated result via its `truncate_token=...`), skills (`skill_list` / `skill_read` / `skill_search` / `skill_create` / `skill_edit`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
Default registry: file ops (including `tree` with a markdown bullet-list default, `flat` paths or classic `decorated` on demand; default noise-dir skips), `run_argv` / `run_argv_batch` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), HTTP `fetch_local` (GET/POST/PUT/DELETE via niquests; private/loopback hosts need a human policy grant, not YOLO) plus `fetch` on DeepSeek providers (public pages as Markdown through the provider's Fetch API), human prompts (`ask_user_line` / `ask_user_choice` / `ask_user_form`), `wait` (line prompt with timeout; Enter disturbs), `request_directory_access` (out-of-workspace access grants), `get_truncated` (resume any truncated result via its `truncate_token=...`), skills (`skill_list` / `skill_read` / `skill_search` / `skill_create` / `skill_edit`), and todo stack tools (`todo_list` / `todo_push` / `todo_pop` / `todo_update` / `todo_clear`).
**Skills** are instruction directories shared with other harnesses: a folder with a `SKILL.md` (YAML frontmatter — `name`, `description`, … — plus a Markdown body) and optional bundled files (scripts, references). Discovery, roots, grants, and the authoring format are documented in [doc/skills.md](doc/skills.md); the short version is that the catalog (name + description) goes into the system prompt, the body is read on demand with `skill_read`, and writing a skill is a separate grant (a timed `once`/`session`/`no` prompt, or `[skills].allow_write`) that is **not** a directory-access grant and is never skipped by YOLO.
**Skills** are instruction directories shared with other harnesses: a folder with a `SKILL.md` (YAML frontmatter — `name`, `description`, … — plus a Markdown body) and optional bundled files (scripts, references). Discovery, roots, grants, and the authoring format are documented in [doc/skills.md](doc/skills.md); the short version is that a session-start notice points the model at them (`skill_list` / `skill_read` / `skill_search`) without listing anything, so what exists (and the bodies) is read on demand rather than injected, and writing a skill is a separate grant (a timed `once`/`session`/`no` prompt, or `[skills].allow_write`) that is **not** a directory-access grant and is never skipped by YOLO.
Safety defaults:
- Paths stay under the workspace unless granted (`request_directory_access` / `[agent].allow_paths`); optional `path_denylist` substrings always apply (`tree` also skips denylisted children by default).
- Paths stay under the workspace unless granted (`request_directory_access` / `[agent].allow_paths`); the refusal names the `mode` that would cover the call; optional `path_denylist` substrings always apply (`tree` also skips denylisted children by default).
- Command basename denylist (e.g. dangerous shells/utilities).
- Destructive tools (delete/move/overwrite) can require confirm (`confirm_destructive`; default deny in non-TTY). Override for the session with `/yolo on|off|once` or startup `--yes` (path/command denylists still apply).
- `write_file` refuses to replace an existing file unless `overwrite=true`; the confirm asks about that replacement.
- PTY sessions: caps, idle TTL, output budget; master FD is non-inheritable; sessions closed on chat exit.
Prefer file tools over full-screen editors (`vim`/`nano`) for edits. `read_pty` sanitizes CSI/controls
so tool results cannot reprogram the host TTY (no host terminal reset on exit).
@@ -361,7 +368,7 @@ See [doc/architecture.md](doc/architecture.md) and [AGENTS.md](AGENTS.md) for de
- **`memory/`** — async SQLAlchemy sessions/messages
- **`tools/`** — workspace tools
- **`cli/`** — Click entry + slash registry (`awaitlet` bridges sync Click to async work)
- Multi-tenant / web (`router/`, real `web/`) are **not** in scope for the single-user CLI (Phase H).
- Multi-tenant web (`web/`) is **not** in scope for the single-user CLI (Phase H).
## License
+2 -1
View File
@@ -19,7 +19,6 @@
- utils: Common utilities for code architecture.
- components: Utilities for class composition.
- memory: Storage controlling for sessions and messages.
- router: Multi-source capability routing (Phase H; not implemented).
- config: Plyngent configuration center (TOML), including ``[plugins]`` and
``[networking]`` (e.g. fetch SSRF Fake-IP CIDR exemptions). Model entries
may override provider ``preset`` / ``url`` for mixed API routing.
@@ -38,4 +37,6 @@
- web: Web service (Phase H; not implemented).
Plugins (third-party entry points, allowlisted under ``[plugins]``): [plugins.md](./plugins.md).
Conversation units (a *round* = one request→response plus its tool batch, a
*turn* = a user message to the explicit stop): [turns-and-rounds.md](./turns-and-rounds.md).
+17 -6
View File
@@ -31,8 +31,8 @@
# Discovery roots in priority order: explicit `paths`, our own user config
# directory (<config dir>/skills), other harnesses' skill directories
# (~/.claude/skills; plus <workspace>/.claude/skills when include_project_roots).
# The catalog (name + description) is folded into the system prompt when tools
# are on; the model reads bodies with skill_read and searches with skill_search.
# A session-start notice describes the tools; what exists is found with
# skill_list, bodies are read with skill_read, contents searched with skill_search.
# Reading needs no grant (the roots are read-only roots); creating or editing a
# skill asks once/session, or stands with allow_write = true. See doc/skills.md.
# [skills]
@@ -40,8 +40,6 @@
# paths = ["~/team-skills", "/srv/skills"]
# discover = ["plyngent", "claude"]
# include_project_roots = true
# inject_catalog = true
# max_catalog_skills = 50
# allow_write = false
# MCP (Model Context Protocol) servers exposed as agent tools.
@@ -107,7 +105,8 @@ max_context_tokens = 200000
# 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).
# budget would leave no room for the answer). A model that only takes adaptive
# thinking ignores it (see the anthropic provider below).
# thinking_budget_tokens = 8000
# Optional: custom compact/summarisation prompts (empty = built-in defaults).
@@ -162,7 +161,10 @@ access_key_or_token = "sk-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:
# # (see the [agent] section above). A model that no longer takes one (from
# # Claude 4.7 on) is retried with adaptive thinking, whose depth is the effort
# # level itself, and a model that cannot stop thinking gets no thinking block
# # at all. Set the budget explicitly to pin it:
# reasoning_effort = "high"
# thinking_budget_tokens = 16000
#
@@ -190,6 +192,15 @@ access_key_or_token = "sk-replace-me"
# # Thinking strength for this provider; per-model entries below may override it.
# # reasoning_effort = "high"
#
# # Local ``web_search`` sources, in fallback order (``[]`` disables the tool).
# # "deepseek" is the platform Search API (POST /v1/tools/search; billed per
# # successful request, 5 QPS). "bing" is a brittle RSS backstop — opt in with
# # ["deepseek", "bing"] when you want a fallback.
# # ``provider_tools`` controls only the hosted search, these sources control
# # plyngent's own tool: on the Anthropic convention ``provider_tools = []``
# # leaves you the local Search API tool instead of the hosted one.
# search_sources = ["deepseek"]
#
# [providers.deepseek.models]
# "deepseek-flash" = { text = true, convention = "responses" }
# "deepseek-v4-pro" = { text = true, reasoning_effort = "max" }
+6 -6
View File
@@ -49,12 +49,10 @@ lists the ones with no directory.
```toml
[skills]
enabled = true # false = no skill tools and no prompt catalog
enabled = true # false = no skill tools
paths = ["~/team-skills"] # explicit roots; `~` expands to your home
discover = ["plyngent", "claude"]
include_project_roots = true
inject_catalog = true # fold the catalog into the system prompt
max_catalog_skills = 50
allow_write = false # true = standing grant for skill writes
```
@@ -70,13 +68,15 @@ a note: the skill stays listable instead of vanishing.
| Field | Meaning |
|-------|---------|
| `name` | Skill name; must be lowercase letters, digits, and single hyphens (max 64). A name that cannot be used falls back to the directory name, with a note. |
| `description` | One line shown in the catalog and in `skill_list`. Missing → the first non-heading paragraph of the body. |
| `description` | One line shown by `skill_list` (and `/skills` for the human). Missing → the first non-heading paragraph of the body. |
| `version`, `license`, `allowed-tools`, … | Kept and shown by `skill_list`; `allowed-tools` is **recorded, not enforced** today. |
## What the agent sees
- **Catalog** (system prompt, `inject_catalog`): names, sources, descriptions —
never the bodies, so a turn only pays for the skill it actually reads.
- **Notice** (a host notice, `agent/notices.py`): when skills are enabled and
tools are on, the session gets one `skills` notice describing the tools; it
lists nothing, so what *exists* is asked for and a turn only pays for the
skill it actually reads.
- `skill_list` — what exists, where it came from, shadowed copies, and the notes
from repaired frontmatter.
- `skill_read NAME [file]` — the `SKILL.md` body (ranges, line numbers, and a
+97
View File
@@ -0,0 +1,97 @@
# Turns and Rounds
Two nested units of work carry most of the loop's state. A **round** is one
request→response exchange with the provider plus the tool calls it asked for; a
**turn** is everything between a user message and the explicit stop that ends it.
A session is a stored sequence of turns.
```
turn ─┬─ round ─┬─ request (hosted tools, history, system prompt)
│ ├─ response (one assistant message)
│ └─ tool batch (0..n calls, executed before the next round)
├─ round …
└─ explicit stop (a round with no tool calls)
```
## Round — one model round trip
A round is one completion request and the single assistant message it produced,
followed by the tool calls that message asked for (one batch). The next round
starts only after that batch has reported results.
- **Identity.** One `UsageEvent` per round: `ChatAgent` counts them into
`last_turn_rounds` (`agent/chat.py`). `AssistantMessageEvent` carries the
response, `ToolCallEvent`/`ToolResultEvent` the batch. The transcript's
`rounds=N` label counts the rounds that asked for tools (every round but the
final one), so `/history` and `last_turn_rounds` can differ by that stop round.
- **Ends.** In one of four ways: with tool calls (the loop continues), with no
tool calls (natural stop → the turn ends), at the output limit
(`TruncatedEvent`, `finish_reason=length`: answer and thinking stay, that
round's tool calls are dropped and a notice is appended; up to
`max_truncation_continues` more rounds of the same turn carry on from there),
or in an error (a retry re-runs the round).
- **Bounds.** `max_rounds` per turn (default `DEFAULT_MAX_ROUNDS = 32`,
`agent/loop.py`). Reaching the allowance yields `MaxRoundsEvent(rounds,
continued=False)` — `rounds` is the allowance that ran out — unless the
`on_limit` hook answers yes (CLI: `y`/`n`/`yyy` prompt in `cli/limits.py`):
then the event says `continued=True` and another `max_rounds` are granted.
- **Parallelism.** Calls of one round run concurrently (`parallel_tools`), but
calls naming the same file serialize through per-file write locks, and the
soft confirm for a round is decided once, before the batch runs
(`ToolRegistry.prepare_batch_confirm`).
- **Round-scoped work.** Request assembly, context reduction / tool-result
truncation over `max_context_tokens`, provider-tool (hosted search) blocks,
and the usage sample are all per round.
- **Not durable on its own.** Only committed prefixes are persisted as they
happen — a complete tool batch with its results, plus trailing developer
notices. A final text answer is committed at turn end, so a turn that dies
mid-round leaves its side effects in history but not a half-written answer.
## Turn — one user message to the explicit stop
A turn begins when the user message is appended (`ChatAgent.run`, which also
persists the message immediately) and ends at the explicit stop.
- **Explicit stop.** The first round whose assistant message has no tool calls.
A turn also ends at a boundary: `max_rounds` without continuing,
truncation-continues exhausted (left for `/retry`), cancellation (Ctrl+C), or
an unrecovered error — the tail is rolled back to the committed prefix and the
user message stays so `/retry` can continue.
- **Rounds per turn.** Zero (a failure before the first response) to many.
`retry` continues the *same* turn — it reuses the user message instead of
appending a new one — so retries never create another turn.
- **Turn-scoped state** (reset at the start of the next turn):
- read-before-edit: line-numbered `read_file` authorizes `edit_lineno` for that
path (mtime-checked) for this turn only (`reset_lineno_tracker`);
- `yyy` limit auto-continue: cleared before and after
`run_turn_with_retries`, so a retry of the same turn keeps it and the next
user turn prompts again;
- `/yolo once`: expires when the turn finishes (`expire_yolo_once`), so it
covers exactly one turn;
- todo nagging: `todo_stack.begin_turn()` clears the "touched" flag; any
`todo_*` tool sets it again, and `needs_review()` at the stop decides whether
a stack untouched this turn gets the review reminder;
- usage totals: `last_turn_usage` and `last_turn_rounds` sum the turn's rounds
(`last_request_usage` keeps the last round's sample).
- **Not turn-scoped.** Session identity and stored rows, the todo stack's
contents, soft-confirm trust (path grants, access/skill grants, `TRUSTABLE`
tool grants), and directive-reminder bands outlive the turn.
## Session
A session is the stored sequence of turns; the transcript groups rows by turn,
and `/history N` addresses a turn by number. Compaction rewrites the history sent
in one request and never moves a turn boundary. A side turn (`run_aside`) is a
turn whose history is forked and never written back.
## Where it lives
| unit | begins | ends | counters / records |
| --- | --- | --- | --- |
| round | provider request | response assembled, tool batch done | `UsageEvent`, `last_turn_rounds`, `MaxRoundsEvent`, `TruncatedEvent` |
| turn | `ChatAgent.run` appends the user message | a round with no tool calls (or a boundary) | session rows, `last_turn_usage` |
| session | first turn | process exit / session switch | store tables, transcript |
Loop mechanics: `agent/loop.py` (`run_chat_loop`); turn bookkeeping:
`agent/chat.py` (`ChatAgent.run` / `retry` / `_run_from_user_message`); retry and
turn-scoped flags: `cli/retry.py`, `cli/limits.py`.
Generated
+67 -63
View File
@@ -231,13 +231,13 @@ files = [
[[package]]
name = "iniconfig"
version = "2.3.0"
version = "2.3.1"
requires_python = ">=3.10"
summary = "brain-dead simple config-ini parsing"
groups = ["dev"]
files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
{file = "iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7"},
{file = "iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960"},
]
[[package]]
@@ -422,24 +422,24 @@ files = [
[[package]]
name = "platformdirs"
version = "4.12.3"
requires_python = ">=3.10"
version = "4.13.3"
requires_python = ">=3.11"
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
groups = ["default"]
files = [
{file = "platformdirs-4.12.3-py3-none-any.whl", hash = "sha256:080f3b39423b5abfca9a23d84c4e9795f54d395cd8459867a8ded44084fcd5f8"},
{file = "platformdirs-4.12.3.tar.gz", hash = "sha256:427fc0bb321ae0c5b037fa03238ca74820437be162e78b4848c4d4055b9b766c"},
{file = "platformdirs-4.13.3-py3-none-any.whl", hash = "sha256:f6ad7f447f24f8a3b82cce5976387428bff894a0eca6c3488f4a17f153c130c4"},
{file = "platformdirs-4.13.3.tar.gz", hash = "sha256:5e567f664eb087ab8521c0179cd8d1bd60857d271136567a39719e28e2d383ce"},
]
[[package]]
name = "pluggy"
version = "1.6.0"
requires_python = ">=3.9"
version = "1.7.0"
requires_python = ">=3.10"
summary = "plugin and hook calling mechanisms for python"
groups = ["dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
{file = "pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec"},
{file = "pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8"},
]
[[package]]
@@ -491,17 +491,21 @@ files = [
[[package]]
name = "pywinpty"
version = "3.0.5"
version = "3.1.0"
requires_python = ">=3.10"
summary = "Pseudo terminal support for Windows from Python."
groups = ["default"]
marker = "sys_platform == \"win32\""
files = [
{file = "pywinpty-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:03bb3c16d691d9242267201830bcd0e64a9b663170e9042bc84b210da9de15ac"},
{file = "pywinpty-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:89c5c6ef08997a3b4b277b214a35fe15cab4dd6d119f0140aa71df5b1168fdbc"},
{file = "pywinpty-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7b566165e0c5fdd6abe167a5ac8b954be6a843eb55a85946576d6bc1dea03d6d"},
{file = "pywinpty-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:24366280a8aa677323da87bec729cb3ea3b35367386cece0978bdc6e4695c690"},
{file = "pywinpty-3.0.5.tar.gz", hash = "sha256:61db0db063de9865adbea66db294628f8577f608d9764a4c7d3384eeacc4e81b"},
{file = "pywinpty-3.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:d01178e9391366bc3325b4c49068cfce95d10d416f6ade62140b274741bc11e0"},
{file = "pywinpty-3.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:44918ef8bc408fbf52f54db9b8c33370aee0a59cee76b071a4b4f41ad5ed89c8"},
{file = "pywinpty-3.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:08afc428ec8765f2b8b9a9954727e1dafbefcb5912acc65952007779449650a1"},
{file = "pywinpty-3.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:98f00e50639e9edb740eb6eedd6c0ab82959f9633029db59363d06973d00ac0d"},
{file = "pywinpty-3.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:188923081eea840c15f37c54a29ad6871b04fddf8fbf1c4acf317d0e27bd9cac"},
{file = "pywinpty-3.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:714e8aa138bc61dcfd26c139d8cb8e7b954ea5c09c98bb014ab9b1f5741e289c"},
{file = "pywinpty-3.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c08a2e5fa7a62f31b5965f0be1b92c67394cd5f75dc0a033d45fadc597f0640d"},
{file = "pywinpty-3.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:751d0c727d0e81a246aba722cf0dcb2155fd6bc7b82574b1432f81256157ec73"},
{file = "pywinpty-3.1.0.tar.gz", hash = "sha256:14595b68054d61f2f1e90cc2b30be47be201de200ae1851c433d8cded101bce0"},
]
[[package]]
@@ -593,7 +597,7 @@ files = [
[[package]]
name = "sqlalchemy"
version = "2.1.3"
version = "2.1.4"
requires_python = ">=3.11"
summary = "Database Abstraction Library"
groups = ["default"]
@@ -601,59 +605,59 @@ dependencies = [
"typing-extensions>=4.6.0",
]
files = [
{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"},
{file = "sqlalchemy-2.1.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a2f6164c0527cd8fc4cea79a5c9d8369ffee417b8ba444a42342f36b91deb75"},
{file = "sqlalchemy-2.1.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6929a11ad26a91a4efd891c1252b373c2e88f056910b83ec6030ed3f2cbcb734"},
{file = "sqlalchemy-2.1.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14528d37d7d46a92f2a483f188f7fecd86cdd789254a0412b960c9fc5e9efd6d"},
{file = "sqlalchemy-2.1.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d2cb669c6bd1f19caf51db6e3c4fdd4cbb76f9db3ef81c3aeb5e288d9bae101b"},
{file = "sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:63dc25b21fd9a41dc09b7aada4b3b0d97cf4b6414f74bced6ac45326bc799ac9"},
{file = "sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:308f96d24e773d64609a2a0d1161a068f9f6e9165523bc4e07aa9c45f0c4213f"},
{file = "sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93b9416b9011a3b7689a933e04ac9f61d15686b6cb1948ebc1f41467153116c3"},
{file = "sqlalchemy-2.1.4-cp314-cp314-win32.whl", hash = "sha256:89db94855287fdac98d74595cf13ea59fbffa608d6400ff972b0fd4c036d873f"},
{file = "sqlalchemy-2.1.4-cp314-cp314-win_amd64.whl", hash = "sha256:080f8d853aac5bb5620f0ae6f46527397cf18dce0ec2b478b478469ef3cae2c4"},
{file = "sqlalchemy-2.1.4-cp314-cp314-win_arm64.whl", hash = "sha256:64d41be1dd88f184de1931f0173f4827122a1b49fd1150656641200c0bdf640c"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84272f329c15081a1e09b4a7261118b4e8a547f43e00fca98e55bbdf19eff3be"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b3f58bd26fc010ea28976d401845e4e6ce02e1b7c0288b3ea9c9a3c396f0bcc"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82d728075d42bd457d09655cf22e99d772a648c6f67e86743a4f05b7d063ca18"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0970394ec5d9e397aafc5bc5fa2b7f8b58cb191f2703006b19a96ef4bf00b8d9"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6005f2f5fcd67fdd721446128e6a2a1d18f77387a604fbd26b0006a086b33096"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0e01a3e199ae219381c4889993c5584b1b905fffe6830f639adb6770036a8913"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:22129e7d00ac66b291840c4dc83a9c497456ab5bffa682dcbfdc2356f9e49e5a"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-win32.whl", hash = "sha256:bc33d3e59d4e84b8866cc9ba13732585e37212dbe3542cb09f232682b36f47a5"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-win_amd64.whl", hash = "sha256:346d144e8912ae087b10d3c2081657cb634728600693eee6dbb71d7eb4768101"},
{file = "sqlalchemy-2.1.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3e5de57c71b3460e2ca6137e82cd3cb8c9f711f301f50d5c77156fdb9c822999"},
{file = "sqlalchemy-2.1.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:418786f05387ddb66ee683a1d016c5a8d9bf7be921e6ee8f285c7b6ac961a731"},
{file = "sqlalchemy-2.1.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:283914efed30e4d44301e36ac90ad048570538b8a70f072fe01578d9b205d09c"},
{file = "sqlalchemy-2.1.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d2eacdbeb990b80235763860923c60a8393745b66f7149a734980c65896da72"},
{file = "sqlalchemy-2.1.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e43fca5fdd5f34a3f8c54107a3648d3139de8bbf596a189f3f0de94bd84949bb"},
{file = "sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2e1b5343d315b10a4a71da481729f66f830a561595e02b61e8a5a65d658325ac"},
{file = "sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:42c37c06adcecf444e8c981f7e9237a41bdd445c83da0df9e08b4ad958becbbc"},
{file = "sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:bab7f51d38766d6a64da2b41976f1b3f9cc2ff37d3f2f63bdbac876199f3a48e"},
{file = "sqlalchemy-2.1.4-cp315-cp315-win32.whl", hash = "sha256:1541ba5bf0f232cd61f9ef3df78c93977c72ba6031506a0e6d057b2a3ddb76e9"},
{file = "sqlalchemy-2.1.4-cp315-cp315-win_amd64.whl", hash = "sha256:596a95611c217cb19c21f02f43c637cb507cab71dcf0467c5c7d98fcdd703007"},
{file = "sqlalchemy-2.1.4-cp315-cp315-win_arm64.whl", hash = "sha256:0d1ca95e42ce3c18818f170b741d30a33b292c6f6b9a202ffd717e28fc99b8c7"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0f672ed6972164fec94a8f0b21dcf8545080d0727866335fb8adf9f4764ce6ec"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72e3fa41d1fdab87d4e88bbdd69c9522e2795549fbe7b07bcf4ae9ec175f4b11"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cb2cb98d056e63e353ed697750004e07c79b054d73059ba3184ca3bb07296bea"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d66fdcc5506e0f8bb8d3f4f95125220a7cd6c46e8b1762750f01e9639973dd8"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:81f802c96dbf96e59c6982fa1b87da7868920fb0c27b9b81e560a62f57c2ccfb"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:acf8982c70471a68aa90d1aba08b48860c55b3357ec84ccb0f09368ead2ce099"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:778094c83e36c430756a7e1a1ac66fc3cffb2c6a1067958fe6b920abcec7bc5a"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-win32.whl", hash = "sha256:963348422b22f760e9462e56bc32bf4d95d224cc5b8c79a3c6e3b786d3d2a2b2"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-win_amd64.whl", hash = "sha256:fba3500e170d25f581e053009edeb0b158116084d91d465de218718d336b67c3"},
{file = "sqlalchemy-2.1.4-cp315-cp315t-win_arm64.whl", hash = "sha256:0a9a464bc360856b7ea9bf8aa26aab92ca115dd08149cb0e004063d5db13584b"},
{file = "sqlalchemy-2.1.4-py3-none-any.whl", hash = "sha256:0b96edcc2cd60fe1e35f67a46f4eb076e57297841b9eae949ac5f196593f00a7"},
{file = "sqlalchemy-2.1.4.tar.gz", hash = "sha256:7bd7ad604487daa7eab8716471c29a7185f17b5287ce73bb7bc79fea050d8cfd"},
]
[[package]]
name = "tomlkit"
version = "0.15.1"
version = "0.15.2"
requires_python = ">=3.9"
summary = "Style preserving TOML library"
groups = ["default"]
files = [
{file = "tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304"},
{file = "tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97"},
{file = "tomlkit-0.15.2-py3-none-any.whl", hash = "sha256:05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437"},
{file = "tomlkit-0.15.2.tar.gz", hash = "sha256:83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6"},
]
[[package]]
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "plyngent"
version = "0.8.0"
version = "0.10.0"
description = "LLM chat and agent toolkit"
authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" },
+7
View File
@@ -17,15 +17,22 @@ from .events import ReasoningDeltaEvent as ReasoningDeltaEvent
from .events import TextDeltaEvent as TextDeltaEvent
from .events import ToolCallEvent as ToolCallEvent
from .events import ToolResultEvent as ToolResultEvent
from .events import TruncatedEvent as TruncatedEvent
from .events import UsageEvent as UsageEvent
from .loop import DEFAULT_MAX_ROUNDS as DEFAULT_MAX_ROUNDS
from .loop import run_chat_loop as run_chat_loop
from .notices import Notice as Notice
from .notices import aside_notice as aside_notice
from .notices import format_moment as format_moment
from .notices import has_notice as has_notice
from .notices import resume_notice as resume_notice
from .notices import skill_tools_notice as skill_tools_notice
from .notices import truncated_turn_notice as truncated_turn_notice
from .tools import BatchConfirmDecision as BatchConfirmDecision
from .tools import DangerClassifier as DangerClassifier
from .tools import ToolBatchConfirmHook as ToolBatchConfirmHook
from .tools import ToolConfirmHook as ToolConfirmHook
from .tools import ToolConfirmRequest as ToolConfirmRequest
from .tools import ToolDefinition as ToolDefinition
from .tools import ToolRegistry as ToolRegistry
from .tools import ToolTag as ToolTag
+16 -4
View File
@@ -27,7 +27,8 @@ from .directive_checkpoint import (
parse_checkpoint_bands,
)
from .events import UsageEvent
from .loop import DEFAULT_MAX_ROUNDS, run_chat_loop
from .loop import DEFAULT_MAX_ROUNDS, DEFAULT_MAX_TRUNCATION_CONTINUES, run_chat_loop
from .notices import TRUNCATED_NOTICE_KIND, notice_of_kind
from .todo_nag import (
DEFAULT_TODO_NAG_STRATEGY,
parse_todo_nag_strategy,
@@ -140,8 +141,10 @@ def incomplete_turn_user_text(messages: Sequence[AnyChatMessage]) -> str | None:
"""User text for an incomplete turn, if history can be retried.
Incomplete when the last non-system message is a user message (failed before
any commit), or tool results after a complete tool batch (failed on a later
model round — keep tools so retry does not re-execute side effects).
any commit), tool results after a complete tool batch (failed on a later
model round — keep tools so retry does not re-execute side effects), or a
partial answer under a truncation notice (the round hit the output limit; its
answer was kept, so retry continues it instead of regenerating).
Trailing developer messages (host notices, directive checkpoints) belong to
the turn but never end it, so they are skipped when reading the tail.
"""
@@ -150,14 +153,17 @@ def incomplete_turn_user_text(messages: Sequence[AnyChatMessage]) -> str | None:
if messages and isinstance(messages[0], SystemChatMessage):
start = 1
body = list(messages[start:])
truncated = False
while body and isinstance(body[-1], DeveloperChatMessage):
if notice_of_kind(body[-1], TRUNCATED_NOTICE_KIND):
truncated = True
_ = body.pop()
if not body:
return None
last = body[-1]
if isinstance(last, UserChatMessage):
return last.content
if isinstance(last, ToolChatMessage):
if isinstance(last, ToolChatMessage) or (truncated and isinstance(last, AssistantChatMessage)):
# Walk back to the user that opened this turn.
for msg in reversed(body):
if isinstance(msg, UserChatMessage):
@@ -289,6 +295,8 @@ class ChatAgent:
memory: MemoryStore | None
session_id: int | None
max_rounds: int
# Rounds a turn may carry on after hitting the output limit (0 = none).
max_truncation_continues: int
temperature: float | None
# Resolved thinking strength ("" = provider default); see config.reasoning.
reasoning_effort: str
@@ -322,6 +330,7 @@ class ChatAgent:
memory: MemoryStore | None = None,
session_id: int | None = None,
max_rounds: int = DEFAULT_MAX_ROUNDS,
max_truncation_continues: int = DEFAULT_MAX_TRUNCATION_CONTINUES,
temperature: float | None = None,
reasoning_effort: str = "",
messages: Sequence[AnyChatMessage] | None = None,
@@ -345,6 +354,7 @@ class ChatAgent:
self.memory = memory
self.session_id = session_id
self.max_rounds = max_rounds
self.max_truncation_continues = max(0, int(max_truncation_continues))
self.temperature = temperature
self.reasoning_effort = reasoning_effort
self.on_limit = on_limit
@@ -603,6 +613,7 @@ class ChatAgent:
model=self.model,
tools=self.tools,
max_rounds=self.max_rounds,
max_truncation_continues=self.max_truncation_continues,
temperature=self.temperature,
reasoning_effort=self.reasoning_effort,
on_limit=self.on_limit,
@@ -747,6 +758,7 @@ class ChatAgent:
memory=None,
session_id=None,
max_rounds=rounds,
max_truncation_continues=self.max_truncation_continues,
temperature=self.temperature,
reasoning_effort=self.reasoning_effort,
on_limit=self.on_limit,
+2
View File
@@ -30,6 +30,8 @@ _SUMMARY_SYSTEM = (
"Write a dense, factual summary that preserves: goals, decisions, "
"file paths touched, commands run, open tasks, and constraints. "
"Omit chit-chat and redundant tool dumps. Use short bullet sections. "
"Tool calls are unavailable in this request: do not call tools or emit "
"tool-call syntax; summarize only what the transcript contains. "
"Do not invent facts not present in the transcript."
)
+4 -2
View File
@@ -21,11 +21,13 @@ Tool playbook still applies (see system). Hard constraints:
use `run_argv_batch` for ordered pipelines.
- `edit_replace`: fix match / `max_replaces`; `edit_lineno` edits only lines read via \
`read_file(with_lineno=true)`, and line numbers reset after any file write.
- `read_file` results start with `L{begin}-{end}` (1-based lines; offset is 0-based).
- `read_file` results start with `L{begin}-{end}` (1-based lines; offset is 0-based; \
`skill_read` slices a skill file the same way).
- `regex_files`: `pattern` is a Python `re` regex (not a glob, so `*.py` fails) and \
`path` is a literal file/dir (no glob expansion).
- Truncated results carry a `truncate_token=...`; continue with `get_truncated`.
- Prefer `fetch` for HTTP(S); private/LAN hosts need human policy allow (not YOLO).
- Prefer `fetch` for public pages (clean Markdown); `fetch_local` for localhost/LAN, \
non-GET methods, headers, or raw status; `offset` (0-based) skips leading body characters.
- PTY secrets only via `ask_into_pty`; denylists and confirms still apply.
- `wait` pauses; pressing Enter disturbs early (optional reason).
- Todo stack: open items mean unfinished work; the stack does not auto-clear — \
+14
View File
@@ -34,6 +34,19 @@ class MaxRoundsEvent(Struct, tag_field="type", tag="max_rounds"):
continued: bool = False
class TruncatedEvent(Struct, tag_field="type", tag="truncated"):
"""A round the provider cut off at the output limit (``finish_reason=length``).
The partial answer stays in history rather than being discarded, and the
model is told where it stopped (see ``notices.truncated_turn_notice``);
``continued`` says whether the turn went on with another round or is waiting
for ``/retry``.
"""
kept_chars: int = 0
continued: bool = False
class ErrorEvent(Struct, tag_field="type", tag="error"):
message: str
retryable: bool = True
@@ -60,6 +73,7 @@ type AgentEvent = (
| ToolCallEvent
| ToolResultEvent
| MaxRoundsEvent
| TruncatedEvent
| ErrorEvent
| CancelledEvent
| UsageEvent
+131 -21
View File
@@ -43,8 +43,11 @@ from .events import (
TextDeltaEvent,
ToolCallEvent,
ToolResultEvent,
TruncatedEvent,
UsageEvent,
)
from .finish_reason import chat_finish_reason
from .notices import truncated_turn_notice
from .todo_nag import (
DEFAULT_TODO_NAG_STRATEGY,
inject_todo_nag_for_stack_with_events,
@@ -61,13 +64,17 @@ if TYPE_CHECKING:
from .todo_nag import TodoNagStrategy
from .todo_stack import TodoStack
from .tools import ToolRegistry
from .tools import BatchConfirmDecision, ToolRegistry
from .types import AnyLLMClient
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
DEFAULT_MAX_ROUNDS = 32
# Rounds cut off at the output limit are kept and the model carries on in another
# round, this many times per turn; 0 stops after the truncated round (`/retry`
# continues it from the kept partial answer).
DEFAULT_MAX_TRUNCATION_CONTINUES = 2
async def _call_on_limit(on_limit: LimitContinueHook, reason: str) -> bool:
@@ -82,10 +89,11 @@ async def _run_one_tool(
call: AnyAssistantToolCall,
*,
max_result_chars: int,
decision: BatchConfirmDecision | None = None,
) -> tuple[ToolChatMessage, ErrorEvent | None]:
if isinstance(call, AssistantFunctionToolCall):
try:
result_text = await tools.execute(call.function.name, call.function.arguments)
result_text = await tools.execute(call.function.name, call.function.arguments, decision=decision)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
@@ -117,12 +125,31 @@ async def _execute_tool_calls(
for call in tool_calls:
yield ToolCallEvent(tool_call=call)
# One confirm prompt for the round's mutating calls, so a batch of moves or
# deletes asks once instead of once per call. Calls that need no confirm (or
# cannot run) keep ``None`` and go through their own path below.
requests: list[tuple[str, str]] = []
positions: list[int] = []
for index, call in enumerate(tool_calls):
if isinstance(call, AssistantFunctionToolCall):
requests.append((call.function.name, call.function.arguments))
positions.append(index)
decisions: list[BatchConfirmDecision | None] = [None] * len(tool_calls)
for index, decision in zip(positions, await tools.prepare_batch_confirm(requests), strict=True):
decisions[index] = decision
if parallel and len(tool_calls) > 1:
results = await asyncio.gather(
*[_run_one_tool(tools, call, max_result_chars=max_result_chars) for call in tool_calls]
*[
_run_one_tool(tools, call, max_result_chars=max_result_chars, decision=decision)
for call, decision in zip(tool_calls, decisions, strict=True)
]
)
else:
results = [await _run_one_tool(tools, call, max_result_chars=max_result_chars) for call in tool_calls]
results = [
await _run_one_tool(tools, call, max_result_chars=max_result_chars, decision=decision)
for call, decision in zip(tool_calls, decisions, strict=True)
]
# Record every result before yielding any: a caller that goes away mid-batch
# (cancel, display error) must not lose work that already ran, or the retry
@@ -136,15 +163,25 @@ async def _execute_tool_calls(
def _assistant_has_payload(assistant: AssistantChatMessage) -> bool:
"""True if the model produced text, reasoning, or tool calls."""
"""True if the model produced text, reasoning, provider blocks, or tool calls."""
if assistant.tool_calls is not UNSET and assistant.tool_calls:
return True
if isinstance(assistant.content, str) and assistant.content.strip():
return True
# A round that only searched (a hosted ``web_search``) kept the search
# blocks as provider blocks: the model did produce something, even with no
# text of its own yet.
if assistant.provider_blocks is not UNSET and assistant.provider_blocks:
return True
reasoning = assistant.reasoning_content
return bool(isinstance(reasoning, str) and reasoning.strip())
def _kept_chars(assistant: AssistantChatMessage) -> int:
"""How much answer a cut-off round had written (0 if only reasoning/blocks)."""
return len(assistant.content) if isinstance(assistant.content, str) else 0
def _finish_reason_value(finish: object) -> str | None:
if finish is UNSET or finish is None:
return None
@@ -158,20 +195,26 @@ def _validate_assistant_terminal(
*,
finish_reason: str | None,
stream_terminal: bool,
) -> None:
"""Raise when the round is not a usable agent stop.
) -> bool:
"""Raise when the round is not a usable agent stop; True if it hit the output limit.
Distinguishes empty generation, truncated/filtered stops, and missing
stream terminals (network/client glitch) from a normal stop/tool_calls end.
Distinguishes empty generation, filtered stops, and missing stream terminals
(network/client glitch) from a normal stop/tool_calls end. A round cut off at
the output limit is not an error of its own: what it wrote is usable, so the
caller keeps it and tells the model where it stopped. A truncated round that
wrote nothing stays a failure — there is nothing to continue from.
"""
reason = (finish_reason or "").lower() or None
canonical = chat_finish_reason(reason, has_tool_calls=False)
if reason in {"length", "content_filter"}:
label = "truncated (max tokens)" if reason == "length" else "content filter"
detail = ""
if isinstance(assistant.content, str) and assistant.content.strip():
detail = f"; partial text kept ({len(assistant.content)} chars)"
msg = f"model stopped early: {label}{detail}"
if canonical == "length":
if _assistant_has_payload(assistant):
return True
msg = "model stopped early: output limit reached with nothing written"
raise RuntimeError(msg)
if canonical == "content_filter":
msg = "model stopped early: content filter"
raise RuntimeError(msg)
if reason in {"failed", "incomplete", "cancelled"}:
@@ -179,7 +222,7 @@ def _validate_assistant_terminal(
raise RuntimeError(msg)
if _assistant_has_payload(assistant):
return
return False
if not stream_terminal and reason is None:
msg = (
@@ -244,7 +287,7 @@ async def _non_stream_round(
choice = response.choices[0]
assistant = choice.message
finish = _finish_reason_value(choice.finish_reason)
_validate_assistant_terminal(
truncated = _validate_assistant_terminal(
assistant,
finish_reason=finish,
stream_terminal=True,
@@ -255,6 +298,8 @@ async def _non_stream_round(
if isinstance(assistant.content, str) and assistant.content:
yield TextDeltaEvent(content=assistant.content)
yield AssistantMessageEvent(message=assistant)
if truncated:
yield TruncatedEvent(kept_chars=_kept_chars(assistant))
yield UsageEvent(
usage=resolve_round_usage(response.usage, param.messages, assistant),
)
@@ -283,6 +328,23 @@ def _take_reasoning_delta(
return [content], [ReasoningDeltaEvent(content=fresh)] if fresh else []
def _accumulate_deltas(
delta: DeltaMessage,
*,
tool_deltas: list[StreamToolCallDelta],
provider_blocks: list[dict[str, Any]],
) -> None:
"""Collect the carriers a round keeps from one chunk's delta.
Tool calls merge after the stream; provider-opaque blocks (a hosted search's
blocks, a thinking block) were rebuilt by the bridge and ride along as-is.
"""
if delta.tool_calls is not UNSET and delta.tool_calls:
tool_deltas.extend(delta.tool_calls)
if delta.provider_blocks is not UNSET and delta.provider_blocks:
provider_blocks.extend(delta.provider_blocks)
async def _stream_round(
client: AnyLLMClient,
param: ChatCompletionsParam,
@@ -306,6 +368,7 @@ async def _stream_round(
)
content_parts: list[str] = []
reasoning_parts: list[str] = []
provider_blocks: list[dict[str, Any]] = []
tool_deltas: list[StreamToolCallDelta] = []
last_api_usage: object = UNSET
finish_reason: str | None = None
@@ -332,8 +395,7 @@ async def _stream_round(
if isinstance(delta.content, str) and delta.content:
content_parts.append(delta.content)
yield TextDeltaEvent(content=delta.content)
if delta.tool_calls is not UNSET and delta.tool_calls:
tool_deltas.extend(delta.tool_calls)
_accumulate_deltas(delta, tool_deltas=tool_deltas, provider_blocks=provider_blocks)
full_content = "".join(content_parts)
full_reasoning = "".join(reasoning_parts)
@@ -347,18 +409,21 @@ async def _stream_round(
content=full_content or None,
tool_calls=tool_calls,
reasoning_content=full_reasoning or UNSET,
provider_blocks=provider_blocks or UNSET,
)
# Usage-only final chunks leave choices empty; a non-empty usage after
# content still is not a finish_reason. Prefer explicit finish_reason.
# If we got payload but no finish_reason, treat as terminal (some providers
# omit it); if empty and no finish_reason, flag as missing terminal.
stream_terminal = saw_terminal or _assistant_has_payload(assistant)
_validate_assistant_terminal(
truncated = _validate_assistant_terminal(
assistant,
finish_reason=finish_reason,
stream_terminal=stream_terminal,
)
yield AssistantMessageEvent(message=assistant)
if truncated:
yield TruncatedEvent(kept_chars=_kept_chars(assistant))
yield UsageEvent(
usage=resolve_round_usage(last_api_usage, param.messages, assistant),
)
@@ -385,6 +450,27 @@ async def _assistant_round(
yield event
def _keep_truncated_round(
messages: list[AnyChatMessage],
truncated: TruncatedEvent,
*,
continued: bool,
) -> TruncatedEvent:
"""Keep a round cut off at the output limit and tell the model where it stopped.
The answer and its thinking blocks stay in ``messages`` (the provider verifies
those blocks, so dropping them would make the round uncontinuable), but the
round's tool calls go: a half-written call must not run, its arguments may not
even parse, and the model re-issues it after reading the notice. Returns the
event to yield, with the continuation outcome filled in.
"""
last = messages[-1]
if isinstance(last, AssistantChatMessage) and last.tool_calls is not UNSET and last.tool_calls:
messages[-1] = msgspec.structs.replace(last, tool_calls=UNSET)
messages.append(truncated_turn_notice().to_message())
return msgspec.structs.replace(truncated, continued=continued)
def _last_assistant(messages: list[AnyChatMessage], pre_len: int) -> AssistantChatMessage:
if len(messages) <= pre_len:
msg = "model round produced no assistant message"
@@ -425,13 +511,14 @@ async def _maybe_inject_directive_checkpoints(
return new_band
async def run_chat_loop( # noqa: C901 — multi-phase tool loop
async def run_chat_loop( # noqa: C901, PLR0915 — multi-phase tool loop
client: AnyLLMClient,
messages: list[AnyChatMessage],
*,
model: str,
tools: ToolRegistry | None = None,
max_rounds: int = DEFAULT_MAX_ROUNDS,
max_truncation_continues: int = DEFAULT_MAX_TRUNCATION_CONTINUES,
temperature: float | None = None,
reasoning_effort: str = "",
on_limit: LimitContinueHook | None = None,
@@ -458,6 +545,12 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
the provider's own default); each API surface maps it onto its own wire field
(see ``config.reasoning``).
A round the provider cuts off at the output limit (``finish_reason=length``)
is kept instead of failing the turn: its answer and thinking blocks stay in
``messages``, its tool calls are dropped, and a truncation notice tells the
model where it stopped. Up to *max_truncation_continues* rounds per turn then
carry on from there (0 = stop, leaving the turn for ``/retry``).
*provider_tools* are hosted/provider-side tools (OpenAI Responses only, e.g.
``web_search``) merged into the request as opaque dicts; never executed by the
:class:`ToolRegistry`. Ignored by non-Responses clients.
@@ -477,6 +570,7 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
rounds_used = 0
allowance = max_rounds
truncation_continues = 0
# Calibrate soft-compact from last model call's prompt_tokens (API preferred).
prompt_tokens_hint: int | None = None
sent_estimate_tokens: int | None = None
@@ -509,6 +603,7 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
pre_len = len(messages)
last_usage_event: UsageEvent | None = None
truncated: TruncatedEvent | None = None
async for event in _assistant_round(
client,
param,
@@ -521,9 +616,24 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
prompt_tokens_hint = event.usage.prompt_tokens
sent_estimate_tokens = sent_est
last_usage_event = event
if isinstance(event, TruncatedEvent):
# Yielded below, once the continuation decision is known.
truncated = event
continue
yield event
assistant = _last_assistant(messages, pre_len)
if truncated is not None:
# The provider cut the round off at the output limit: keep what it
# wrote and tell the model where it stopped (see the helper).
continued = truncation_continues < max_truncation_continues
if continued:
truncation_continues += 1
yield _keep_truncated_round(messages, truncated, continued=continued)
if continued:
continue
return
tool_calls = assistant.tool_calls
has_tools = tool_calls is not UNSET and bool(tool_calls) and tools is not None
if has_tools:
+139 -8
View File
@@ -17,16 +17,19 @@ from plyngent.lmproto.anthropic.model import (
AnthropicAssistantMessage,
AnthropicMessageResponse,
AnthropicMessagesParam,
AnthropicOutputConfig,
AnthropicResponseText,
AnthropicResponseToolUse,
AnthropicServerToolUseContent,
AnthropicTextContent,
AnthropicThinkingConfig,
AnthropicThinkingContent,
AnthropicToolChoice,
AnthropicToolDefinition,
AnthropicToolResultContent,
AnthropicToolUseContent,
AnthropicUsage,
AnthropicUserMessage,
AnthropicWebSearchToolResultContent,
)
from plyngent.lmproto.openai_compatible.model import (
AnyAssistantToolCall,
@@ -133,7 +136,19 @@ def _tool_choice_from_chat(param: ChatCompletionsParam) -> AnthropicToolChoice |
def _assistant_to_anthropic(message: AssistantChatMessage) -> AnthropicAssistantMessage | None:
blocks: list[AnthropicTextContent | AnthropicToolUseContent] = []
"""The assistant turn for the wire: opaque blocks, then text, then calls.
``provider_blocks`` go back first and verbatim (a ``thinking`` block's
signature is only the provider's to verify, and a hosted search's
``web_search_tool_result`` entries carry an ``encrypted_content`` the API
rejects when trimmed), which is also the order the API sends them in. A live
probe takes that shape back — the round after a search, with and without a
local ``tool_use`` after the blocks — and the model then answers from the
results it already holds instead of searching again.
"""
blocks: list[AnthropicTextContent | AnthropicToolUseContent | dict[str, Any]] = []
if message.provider_blocks is not UNSET and message.provider_blocks:
blocks.extend(message.provider_blocks)
if isinstance(message.content, str) and message.content:
blocks.append(AnthropicTextContent(text=message.content))
if message.tool_calls is not UNSET and message.tool_calls:
@@ -219,22 +234,59 @@ def chat_messages_to_anthropic( # noqa: C901 — multi-role conversion
return system, out
def _thinking_block_json(block: AnthropicThinkingContent) -> dict[str, Any]:
"""A ``thinking`` block as JSON: its text, plus the signature when sent."""
out: dict[str, Any] = {"type": "thinking", "thinking": block.thinking}
if isinstance(block.signature, str):
out["signature"] = block.signature
return out
def _server_block_json(
block: AnthropicServerToolUseContent | AnthropicWebSearchToolResultContent,
) -> dict[str, Any]:
"""A hosted search's block as JSON: the query it ran, or the raw entries.
Rebuilt from the parsed block: an ``encrypted_content`` (and any other key
inside the loose ``content`` entries) survives byte for byte, while a key
the block model does not name cannot come back.
"""
if isinstance(block, AnthropicServerToolUseContent):
out: dict[str, Any] = {"type": "server_tool_use", "id": block.id, "name": block.name, "input": block.input}
if block.caller is not UNSET:
out["caller"] = block.caller
return out
return {"type": "web_search_tool_result", "tool_use_id": block.tool_use_id, "content": block.content}
def anthropic_response_to_assistant(response: AnthropicMessageResponse) -> 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.
``thinking`` lands in ``reasoning_content`` (the chat-shaped slot every
surface's chain-of-thought goes into) and, signature included, in
``provider_blocks`` — the API wants the block back to continue a turn it
reasoned through. A hosted search's ``server_tool_use`` +
``web_search_tool_result`` pair is not a call for the local registry, but it
is the model's own evidence: both go into ``provider_blocks`` verbatim, so
the next request hands them back and the model does not search again.
"""
text_parts: list[str] = []
thinking_parts: list[str] = []
provider_blocks: list[dict[str, Any]] = []
tool_calls: list[AnyAssistantToolCall] = []
for block in response.content:
if isinstance(block, AnthropicResponseText):
if block.text:
text_parts.append(block.text)
continue
if not isinstance(block, AnthropicResponseToolUse):
if isinstance(block, AnthropicThinkingContent):
thinking_parts.append(block.thinking)
provider_blocks.append(_thinking_block_json(block))
continue
if isinstance(block, (AnthropicServerToolUseContent, AnthropicWebSearchToolResultContent)):
provider_blocks.append(_server_block_json(block))
continue
# The union's remaining member is the local tool call.
tool_calls.append(
AssistantFunctionToolCall(
id=block.id,
@@ -248,6 +300,8 @@ def anthropic_response_to_assistant(response: AnthropicMessageResponse) -> Assis
return AssistantChatMessage(
content=text or None,
tool_calls=tool_calls or UNSET,
reasoning_content="".join(thinking_parts) or UNSET,
provider_blocks=provider_blocks or UNSET,
)
@@ -331,7 +385,7 @@ def _thinking_from_param(
*,
thinking_budget_tokens: int,
) -> AnthropicThinkingConfig | Unset:
"""Build the ``thinking`` block for *param* (UNSET = send nothing).
"""Build the extended-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
@@ -370,11 +424,77 @@ def _thinking_and_max_tokens(
return max_tokens, thinking
# Effort level → Anthropic ``output_config.effort`` on an adaptive-thinking
# model. ``minimal`` has no API level of its own and lands on the lowest one;
# unknown levels fall back to "medium" (the budgets above do the same).
_ANTHROPIC_ADAPTIVE_EFFORTS: dict[str, str] = {
"minimal": "low",
"low": "low",
"medium": "medium",
"high": "high",
"xhigh": "xhigh",
"max": "max",
}
_ANTHROPIC_FALLBACK_ADAPTIVE_EFFORT = "medium"
def _adaptive_thinking_from_param(
param: ChatCompletionsParam,
) -> tuple[AnthropicThinkingConfig | Unset, AnthropicOutputConfig | Unset]:
"""Build the adaptive-thinking blocks for *param* (UNSET = send nothing).
The shape for a model that rejects ``thinking.type.enabled``: adaptive
thinking, steered by ``output_config.effort`` rather than a token budget.
``reasoning_effort = "none"`` still disables thinking; a configured
``thinking_budget_tokens`` has no adaptive equivalent and is left out.
"""
effort = param.reasoning_effort
if effort is UNSET:
return UNSET, UNSET
if effort == "none":
return AnthropicThinkingConfig(type="disabled"), UNSET
level = _ANTHROPIC_ADAPTIVE_EFFORTS.get(str(effort), _ANTHROPIC_FALLBACK_ADAPTIVE_EFFORT)
return AnthropicThinkingConfig(type="adaptive"), AnthropicOutputConfig(effort=level)
def thinking_config_refused(message: str) -> bool:
"""Whether a ``/messages`` failure is the API refusing a request's thinking block.
The documented wordings are ``"thinking.type.enabled" is not supported for
this model. Use "thinking.type.adaptive" and "output_config.effort" …`` and
the same sentence for ``"thinking.type.disabled"``; gateways paraphrase them
(``claude-opus-5-5 requires adaptive thinking; omit thinking or use
thinking.type=adaptive …``), so match the one word they all carry — callers
only get here after a request that sent a thinking block.
"""
return "adaptive" in message.lower()
def _thinking_for_param(
param: ChatCompletionsParam,
*,
thinking_budget_tokens: int,
adaptive: bool,
) -> tuple[int, AnthropicThinkingConfig | Unset, AnthropicOutputConfig | Unset]:
"""Resolve *param*'s ``max_tokens`` and thinking blocks for one shape.
Extended thinking needs ``max_tokens`` raised above its budget (see
:func:`_thinking_and_max_tokens`); adaptive thinking carries no budget, so
the caller's ``max_tokens`` stands.
"""
if adaptive:
thinking, output_config = _adaptive_thinking_from_param(param)
return _max_tokens_from_param(param), thinking, output_config
max_tokens, thinking = _thinking_and_max_tokens(param, thinking_budget_tokens=thinking_budget_tokens)
return max_tokens, thinking, UNSET
def chat_param_to_anthropic_param(
param: ChatCompletionsParam,
*,
thinking_budget_tokens: int = 0,
provider_tools: Sequence[dict[str, Any]] | None = None,
adaptive_thinking: bool = False,
) -> AnthropicMessagesParam:
"""Build :class:`AnthropicMessagesParam` from a chat completions param.
@@ -382,13 +502,22 @@ def chat_param_to_anthropic_param(
(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.
*adaptive_thinking* asks for the thinking shape of a model that rejects
extended thinking: ``thinking.type = "adaptive"`` with the effort in
``output_config`` instead of a ``budget_tokens`` budget (see
:func:`_adaptive_thinking_from_param`).
"""
system, messages = chat_messages_to_anthropic(param.messages)
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)
max_tokens, thinking, output_config = _thinking_for_param(
param,
thinking_budget_tokens=thinking_budget_tokens,
adaptive=adaptive_thinking,
)
kwargs: dict[str, Any] = {
"model": param.model,
@@ -397,6 +526,8 @@ def chat_param_to_anthropic_param(
}
if thinking is not UNSET:
kwargs["thinking"] = thinking
if output_config is not UNSET:
kwargs["output_config"] = output_config
if system:
kwargs["system"] = system
if tools:
+231 -19
View File
@@ -8,16 +8,23 @@ enter it.
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Final
import json
from typing import TYPE_CHECKING, Any, Final, cast
import msgspec
from msgspec import UNSET
from plyngent.agent.messages_bridge import (
anthropic_response_to_chat_completion,
anthropic_stop_to_finish_reason,
anthropic_usage_to_dict,
chat_param_to_anthropic_param,
thinking_config_refused,
)
from plyngent.agent.stream_chunks import (
finish_reason_chunk,
provider_blocks_chunk,
reasoning_delta_chunk,
text_delta_chunk,
tool_call_delta_chunk,
usage_chunk,
@@ -25,6 +32,7 @@ from plyngent.agent.stream_chunks import (
from plyngent.lmproto.anthropic.model import (
AnthropicContentBlockDelta,
AnthropicContentBlockStart,
AnthropicContentBlockStop,
AnthropicErrorEvent,
AnthropicMessageDelta,
AnthropicMessageStart,
@@ -36,7 +44,7 @@ if TYPE_CHECKING:
from collections.abc import AsyncIterator, Sequence
from plyngent.lmproto.anthropic.client import AnthropicClient
from plyngent.lmproto.anthropic.model import AnthropicStreamEvent
from plyngent.lmproto.anthropic.model import AnthropicMessagesParam, AnthropicStreamEvent
from plyngent.lmproto.openai_compatible.model import (
ChatCompletionChunk,
ChatCompletionResponse,
@@ -49,6 +57,61 @@ def _thinking_budget(client: AnthropicClient) -> int:
return max(0, int(getattr(client, "thinking_budget_tokens", 0)))
# The thinking types a refusal has a fallback for: extended thinking becomes
# adaptive, and a block disabling thinking is dropped (such a model thinks
# whatever the request says).
_RETRYABLE_THINKING_TYPES: Final = frozenset({"enabled", "disabled"})
def _create_param(
client: AnthropicClient,
param: ChatCompletionsParam,
provider_tools: Sequence[dict[str, Any]] | None,
) -> AnthropicMessagesParam:
"""Build the ``/messages`` request for *param*, the way its model now takes it.
A model this session found refusing extended thinking gets adaptive thinking;
one that refuses being told to stop thinking is sent no block at all (its
thinking is always on, so the API answers a block that disables it with a
400 — see :func:`_retry_with_thinking_fallback`).
"""
model = param.model
create = chat_param_to_anthropic_param(
param,
thinking_budget_tokens=_thinking_budget(client),
provider_tools=provider_tools,
adaptive_thinking=client.refuses_thinking_type(model, "enabled"),
)
if (
create.thinking is not UNSET
and create.thinking.type == "disabled"
and client.refuses_thinking_type(model, "disabled")
):
return msgspec.structs.replace(create, thinking=UNSET)
return create
def _retry_with_thinking_fallback(
client: AnthropicClient,
param: ChatCompletionsParam,
provider_tools: Sequence[dict[str, Any]] | None,
error: Exception,
) -> AnthropicMessagesParam | None:
"""The request to retry with when the model refused the thinking block.
``None`` when the failure is not that refusal, or when there is nothing left
to try. The refusal is remembered on the client, so only the model's first
request pays the extra round trip.
"""
if not thinking_config_refused(str(error)):
return None
create = _create_param(client, param, provider_tools)
if create.thinking is UNSET or create.thinking.type not in _RETRYABLE_THINKING_TYPES:
return None
client.note_refused_thinking_type(param.model, create.thinking.type)
return _create_param(client, param, provider_tools)
class _ToolBlockState:
"""Tracks an in-flight tool_use content block during SSE streaming."""
@@ -65,6 +128,92 @@ class _ToolBlockState:
self.tool_index = tool_index
# Block types the chat-shaped round carries itself: text becomes the message's
# content and ``tool_use`` a local tool call. Every other type is the surface's
# own (``thinking``, ``server_tool_use``, ``web_search_tool_result``) and is
# rebuilt so the round can hand it back.
_LOCAL_BLOCK_TYPES: Final = frozenset({"text", "tool_use"})
# The two fields a *delta* owns; a block's own JSON skips them.
_DELTA_ONLY_FIELDS: Final = frozenset({"text", "partial_json"})
def _raw_block_json(block: AnthropicRawContentBlock) -> dict[str, Any]:
"""The block's own JSON: every field the stream set, under its own name.
Field-driven, so a field added to the block model comes along without a
second list to keep in sync.
"""
out: dict[str, Any] = {"type": block.type}
for name in block.__struct_fields__:
if name in _DELTA_ONLY_FIELDS:
continue
value: object = getattr(block, name)
if value is not None:
out[name] = value
return out
def _parse_input_fragments(fragments: list[str]) -> dict[str, Any]:
"""The ``input`` a ``server_tool_use`` streamed as ``partial_json`` chunks."""
try:
parsed = json.loads("".join(fragments))
except json.JSONDecodeError:
return {}
if isinstance(parsed, dict):
return cast("dict[str, Any]", parsed)
return {}
class _ProviderBlockState:
"""A surface-owned block rebuilt from its start payload and fragments.
Nothing local asked for it, but it goes back on the wire with the round (see
``AssistantChatMessage.provider_blocks``): a ``thinking`` block's text and
its opaque ``signature``, and the search query a ``server_tool_use`` streams
as ``partial_json``.
"""
__slots__: Final = ("json_fragments", "parts", "raw", "signature")
raw: dict[str, Any]
parts: list[str]
json_fragments: list[str]
signature: str
def __init__(self, raw: dict[str, Any]) -> None:
self.raw = raw
self.parts = []
self.json_fragments = []
self.signature = ""
def add(self, delta: AnthropicRawContentBlock) -> str | None:
"""Fold one fragment in; return the thinking text it carried, if any."""
if isinstance(delta.partial_json, str) and delta.partial_json:
self.json_fragments.append(delta.partial_json)
return None
if isinstance(delta.signature, str) and delta.signature:
self.signature = delta.signature
return None
# Anthropic streams a thinking fragment as ``thinking``; DeepSeek's
# compat also spells the same fragment ``text``.
fragment = delta.thinking if isinstance(delta.thinking, str) else delta.text
if not isinstance(fragment, str) or not fragment:
return None
self.parts.append(fragment)
return fragment
def finish(self) -> dict[str, Any]:
"""The block to hand back, fragments folded into its start payload."""
block = dict(self.raw)
if self.parts:
block["thinking"] = f"{block.get('thinking', '')}{''.join(self.parts)}"
if self.json_fragments:
block["input"] = _parse_input_fragments(self.json_fragments)
if self.signature:
block["signature"] = self.signature
return block
def _merge_usage(previous: dict[str, Any] | None, new: dict[str, Any]) -> dict[str, Any]:
"""Merge ``message_start`` usage with the later ``message_delta`` usage.
@@ -99,6 +248,7 @@ class _StreamState:
model: str
tool_blocks: dict[int, _ToolBlockState]
block_types: dict[int, str]
provider_blocks: dict[int, _ProviderBlockState]
next_tool_index: int
stop_reason: str | None
last_usage: dict[str, Any] | None
@@ -109,6 +259,7 @@ class _StreamState:
self.model = model
self.tool_blocks = {}
self.block_types = {}
self.provider_blocks = {}
self.next_tool_index = 0
self.stop_reason = None
self.last_usage = None
@@ -140,13 +291,17 @@ def _handle_block_start(
) -> 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``).
A surface-owned block (``server_tool_use``, its ``web_search_tool_result``,
``thinking``) is rebuilt from here on so the round can hand it back (see
``AssistantChatMessage.provider_blocks``); recording its type also keeps the
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 not in _LOCAL_BLOCK_TYPES:
state.provider_blocks[event.index] = _ProviderBlockState(_raw_block_json(block))
return []
if block.type != "tool_use":
return []
call_id = block.id or f"toolu_{event.index}"
@@ -172,6 +327,17 @@ def _handle_message_delta(event: AnthropicMessageDelta, state: _StreamState) ->
state.last_usage = _merge_usage(state.last_usage, usage)
def _chunks_for_block_stop(
event: AnthropicContentBlockStop,
state: _StreamState,
) -> list[ChatCompletionChunk]:
"""Emit a surface-owned block once its fragments are folded in."""
provider = state.provider_blocks.pop(event.index, None)
if provider is None:
return []
return [provider_blocks_chunk(model=state.model, blocks=[provider.finish()])]
def _chunks_for_event(event: AnthropicStreamEvent, state: _StreamState) -> list[ChatCompletionChunk]:
"""Convert one Anthropic stream event into zero or more chat chunks."""
if isinstance(event, AnthropicErrorEvent):
@@ -188,6 +354,9 @@ def _chunks_for_event(event: AnthropicStreamEvent, state: _StreamState) -> list[
if isinstance(event, AnthropicContentBlockStart):
return _handle_block_start(event, state)
if isinstance(event, AnthropicContentBlockStop):
return _chunks_for_block_stop(event, state)
if isinstance(event, AnthropicContentBlockDelta):
return _chunks_for_content_delta(event, state)
@@ -201,11 +370,31 @@ def _chunks_for_event(event: AnthropicStreamEvent, state: _StreamState) -> list[
return []
def _chunks_for_provider_delta(
delta: AnthropicRawContentBlock,
provider: _ProviderBlockState,
*,
model: str,
) -> list[ChatCompletionChunk]:
"""One fragment of a surface-owned block: its reasoning text, or nothing."""
fragment = provider.add(delta)
if fragment is None:
return []
return [reasoning_delta_chunk(model=model, content=fragment)]
def _chunks_for_content_delta(
event: AnthropicContentBlockDelta,
state: _StreamState,
) -> list[ChatCompletionChunk]:
delta: AnthropicRawContentBlock = event.delta
provider = state.provider_blocks.get(event.index)
if provider is not None:
# A surface-owned block's fragments never become local output here: the
# thinking text streams as reasoning, while the search query and the
# signature only go back with the round.
return _chunks_for_provider_delta(delta, provider, model=state.model)
if delta.type in {"text_delta", "text"} and isinstance(delta.text, str) and delta.text:
return [text_delta_chunk(model=state.model, content=delta.text)]
@@ -235,6 +424,33 @@ def _chunks_for_content_delta(
]
async def _stream_events(
client: AnthropicClient,
param: ChatCompletionsParam,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> AsyncIterator[AnthropicStreamEvent]:
"""Open the Messages stream, retrying once with the shape the model takes.
A refusal arrives before the stream yields anything, so the retry cannot
duplicate events the caller has already seen.
"""
create = _create_param(client, param, provider_tools)
saw_event = False
try:
async for event in await client.messages(create, stream=True):
saw_event = True
yield event
except RuntimeError as exc:
if saw_event:
raise
retry = _retry_with_thinking_fallback(client, param, provider_tools, exc)
if retry is None:
raise
async for event in await client.messages(retry, stream=True):
yield event
async def _stream_as_chat_chunks(
client: AnthropicClient,
param: ChatCompletionsParam,
@@ -249,15 +465,9 @@ 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,
thinking_budget_tokens=_thinking_budget(client),
provider_tools=provider_tools,
)
stream = await client.messages(create, stream=True)
state = _StreamState(param.model)
async for event in stream:
async for event in _stream_events(client, param, provider_tools=provider_tools):
for chunk in _chunks_for_event(event, state):
yield chunk
if state.saw_message_stop:
@@ -289,14 +499,16 @@ async def dispatch_messages(
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, provider_tools=provider_tools)
response = await client.messages(create, stream=False)
create = _create_param(client, param, provider_tools)
try:
response = await client.messages(create, stream=False)
except RuntimeError as exc:
retry = _retry_with_thinking_fallback(client, param, provider_tools, exc)
if retry is None:
raise
response = await client.messages(retry, stream=False)
return anthropic_response_to_chat_completion(response)
+72 -2
View File
@@ -14,13 +14,24 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from plyngent.lmproto.openai_compatible.model import DeveloperChatMessage
if TYPE_CHECKING:
from collections.abc import Sequence
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
# Marker prefix so hosts and the model can tell notices apart from other
# developer messages (durable directive checkpoints share the role).
NOTICE_MARKER = "[plyngent notice"
# Notice kind for a turn the provider cut off at the output limit. The turn's
# partial answer is kept, so the agent reads such a turn as unfinished and
# ``/retry`` continues it (see ``ChatAgent.pending_retry_text``).
TRUNCATED_NOTICE_KIND = "truncated"
@dataclass(frozen=True, slots=True)
class Notice:
@@ -35,6 +46,20 @@ class Notice:
return DeveloperChatMessage(content=f"{NOTICE_MARKER}: {self.kind}]\n{self.body.strip()}")
def notice_of_kind(message: AnyChatMessage, kind: str) -> bool:
"""Whether *message* is a notice of *kind*."""
return isinstance(message, DeveloperChatMessage) and message.content.startswith(f"{NOTICE_MARKER}: {kind}]")
def has_notice(messages: Sequence[AnyChatMessage], kind: str) -> bool:
"""Whether *messages* already carries a notice of *kind*.
Notices are persisted with the transcript, so a host asks this before
pushing one that belongs to the session rather than to an event.
"""
return any(notice_of_kind(message, kind) for message in messages)
def format_moment(moment: datetime | None) -> str:
"""Local ``YYYY-MM-DD HH:MM`` stamp for notices; ``unknown`` when absent."""
if moment is None:
@@ -66,11 +91,32 @@ def resume_notice(*, last_active: datetime | None) -> Notice:
return Notice(kind="resume", body=body, summary=summary)
def truncated_turn_notice() -> Notice:
"""Notice for a round the provider cut off at the output limit.
The partial answer is kept in the transcript (thinking blocks included, so
the provider still verifies them), which is why the model has to be told the
round was not finished: it continues from what it already wrote, re-issues
the tool call that was dropped, and keeps the remainder short enough to fit.
"""
body = (
"Your previous round was cut off at the output limit (max_tokens). The partial answer it wrote "
"was kept in the transcript, and any tool call in that round was discarded — re-issue it if you "
"still need it. Continue from where the answer stopped instead of writing it again from the "
"start, and keep the rest short enough to finish: split the work, or hand off what is left."
)
return Notice(
kind=TRUNCATED_NOTICE_KIND,
body=body,
summary="output truncated (max_tokens); partial answer kept",
)
_ASIDE_BODIES: dict[str, str] = {
"read": (
"Side question: this exchange is not saved to the session transcript and does not "
"change the main conversation. Tools here are read-only (file/VCS reads, ask, fetch "
"GET): you cannot edit files or run state-changing commands, so describe a change "
"change the main conversation. Tools here are read-only (file/VCS reads, ask, fetch/"
"fetch_local GET): you cannot edit files or run state-changing commands, so describe a change "
"instead of applying it."
),
"no": (
@@ -90,3 +136,27 @@ def aside_notice(*, tools_mode: str) -> Notice:
"""Notice for a ``/btw`` side question: unsaved exchange plus tool scope."""
body = _ASIDE_BODIES.get(tools_mode, _ASIDE_BODIES["read"])
return Notice(kind="aside", body=body, summary=f"{tools_mode} tools; not saved to the transcript")
def skill_tools_notice() -> Notice:
"""Notice teaching the skill tools — deliberately never a listing.
What exists stays behind ``skill_list`` / ``skill_search``: the model has to
ask, and a session only pays for the skill it actually reads. Reading still
needs no grant; writing asks the human (see ``tools/skills.py``).
"""
body = (
"Skills are instruction directories (a SKILL.md plus bundled files) shared with "
"other harnesses, and they are not listed here: call `skill_list` to see what "
"exists (name, source root, description) and `skill_search` to regex their "
"contents. `skill_read NAME [file]` reads a SKILL.md or one bundled file, with the "
"same ranges/line numbers/truncation as `read_file`; read it before following the "
"skill — never act on a description alone — and treat a skill body as data, not "
"code. `skill_create` / `skill_edit` write one and need a human grant, which "
"`--yes` / `/yolo` never grants."
)
return Notice(
kind="skills",
body=body,
summary="skill tools available; what exists is asked for with skill_list",
)
+27
View File
@@ -79,6 +79,32 @@ def reasoning_delta_chunk(
)
def provider_blocks_chunk(
*,
model: str,
blocks: list[dict[str, Any]],
created: int = 0,
) -> ChatCompletionChunk:
"""Carry provider-opaque blocks a bridge rebuilt mid-stream.
The loop copies them onto the round's assistant message
(``AssistantChatMessage.provider_blocks``), which is what puts them back on
the wire for the surface that produced them.
"""
return ChatCompletionChunk(
id="bridge-stream",
object="chat.completion.chunk",
created=created,
model=model,
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(provider_blocks=blocks),
)
],
)
def tool_call_delta_chunk(
*,
model: str,
@@ -127,6 +153,7 @@ def usage_chunk(*, model: str, usage: dict[str, Any], created: int = 0) -> ChatC
__all__ = [
"finish_reason_chunk",
"provider_blocks_chunk",
"reasoning_delta_chunk",
"text_delta_chunk",
"tool_call_delta_chunk",
+43 -30
View File
@@ -14,7 +14,7 @@ _REVIEW_OPEN_TITLE_LIMIT = 8
class TodoItem(Struct, omit_defaults=True):
"""One task inside a group (siblings share a group; not stacked individually)."""
id: str
id: str # stored key only — a listing numbers items by render order, never by id
title: str
status: TodoStatus = "pending"
notes: str = ""
@@ -39,7 +39,8 @@ class TodoStack:
- **Push** always creates **one new group** containing one or more sibling
tasks (``push T1, T2`` is one frame, not two stack levels).
- **Pop** removes the entire **top group**.
- Within a group, update tasks by id (done / in_progress / …).
- **Update** takes the 1-based number a listing shows (TOP group first); the
stored ``id`` never leaves the stack.
Breakdown pattern::
@@ -161,13 +162,26 @@ class TodoStack:
raw = cast("dict[object, object]", out)
return {str(key): value for key, value in raw.items()}
def _render_order(self) -> list[tuple[TodoGroup, int, TodoItem]]:
"""``(group, index, item)`` in listing order — TOP group first.
A listing numbers these 1-based, so an item's position here *is* the
number it is shown (and updated) by.
"""
ordered: list[tuple[TodoGroup, int, TodoItem]] = []
for group in reversed(self._data.groups):
ordered.extend((group, index, item) for index, item in enumerate(group.items))
return ordered
def render(self) -> str:
if not self._data.groups:
return "(todo stack empty — no groups)"
lines: list[str] = [
f"(LIFO of groups: depth={self.depth}; TOP group = current breakdown level)",
]
# Top group first for the model.
# Top group first for the model; the running number is the item's
# position in :meth:`_render_order`, the same list ``update`` resolves.
number = 0
for offset, group in enumerate(reversed(self._data.groups)):
depth = self.depth - 1 - offset
tag = " TOP" if offset == 0 else ""
@@ -176,6 +190,7 @@ class TodoStack:
lines.append(" (empty group)")
continue
for item in group.items:
number += 1
mark = {
"pending": "[ ]",
"in_progress": "[~]",
@@ -183,7 +198,7 @@ class TodoStack:
"cancelled": "[-]",
}.get(item.status, "[?]")
note = f" — {item.notes}" if item.notes else ""
lines.append(f" {mark} {item.id}: {item.title}{note}")
lines.append(f" {mark} {number}. {item.title}{note}")
return "\n".join(lines)
def turn_reminder_prompt(self) -> str:
@@ -220,7 +235,7 @@ class TodoStack:
headline,
action,
"Tools: todo_list | todo_push(titles=[...]) | todo_update | todo_pop | todo_clear",
"Rules: TOP = current level; push = one sibling group; pop = whole TOP group.",
"Rules: TOP = current level; push = one sibling group; pop = whole TOP group; update by listing number.",
"Stack:",
self.render(),
]
@@ -232,7 +247,7 @@ class TodoStack:
n_open = len(open_items)
n_groups = self.depth
if n_open:
open_titles = "; ".join(f"{item.id}:{item.title}" for item in open_items[:_REVIEW_OPEN_TITLE_LIMIT])
open_titles = "; ".join(item.title for item in open_items[:_REVIEW_OPEN_TITLE_LIMIT])
if n_open > _REVIEW_OPEN_TITLE_LIMIT:
open_titles += f"; …(+{n_open - _REVIEW_OPEN_TITLE_LIMIT} more)"
headline = (
@@ -258,7 +273,7 @@ class TodoStack:
headline,
action,
"Tools: todo_list | todo_push(titles=[...]) | todo_update | todo_pop | todo_clear",
"Rules: TOP group = current level; push=one sibling group; pop=remove whole TOP group.",
"Rules: TOP = current level; push = one sibling group; pop = whole TOP group; update by listing number.",
"Stack:",
self.render(),
]
@@ -326,36 +341,34 @@ class TodoStack:
self.mark_touched()
return n
def get(self, item_id: str) -> TodoItem | None:
for item in self.all_items():
if item.id == item_id:
return item
return None
def update(
self,
item_id: str,
number: int,
*,
title: str | None = None,
status: TodoStatus | None = None,
notes: str | None = None,
) -> TodoItem:
for group in self._data.groups:
for index, item in enumerate(group.items):
if item.id != item_id:
continue
new_title = title.strip() if title is not None else item.title
if not new_title:
msg = "title must not be empty"
raise ValueError(msg)
new_status = status if status is not None else item.status
new_notes = notes if notes is not None else item.notes
updated = TodoItem(id=item.id, title=new_title, status=new_status, notes=new_notes)
group.items[index] = updated
self.mark_touched()
return updated
msg = f"unknown todo id {item_id!r}"
raise KeyError(msg)
"""Update the item a listing shows as *number* (1-based, TOP group first).
Numbers are listing positions, not stored ids: a push/pop shifts them, so
they have to come from the latest listing.
"""
ordered = self._render_order()
if not 1 <= number <= len(ordered):
msg = f"no todo item {number}: the list has {len(ordered)} item(s); pick a number from the latest listing"
raise ValueError(msg)
group, index, item = ordered[number - 1]
new_title = title.strip() if title is not None else item.title
if not new_title:
msg = "title must not be empty"
raise ValueError(msg)
new_status = status if status is not None else item.status
new_notes = notes if notes is not None else item.notes
updated = TodoItem(id=item.id, title=new_title, status=new_status, notes=new_notes)
group.items[index] = updated
self.mark_touched()
return updated
def parse_push_titles(raw: str) -> list[str]:
+227 -23
View File
@@ -3,8 +3,11 @@ from __future__ import annotations
import asyncio
import inspect
import types
from collections.abc import Awaitable, Callable, Mapping
from collections.abc import Awaitable, Callable, Mapping, Sequence
from contextlib import AsyncExitStack
from dataclasses import dataclass
from enum import Flag, auto
from pathlib import Path
from typing import Any, cast, get_args, get_origin, get_type_hints, overload
import msgspec
@@ -21,6 +24,39 @@ type ToolConfirmHook = Callable[
ToolConfirmResult | Awaitable[ToolConfirmResult],
]
@dataclass(frozen=True, slots=True)
class ToolConfirmRequest:
"""One call of a batch that needs a soft confirm before it may run."""
name: str
args: Mapping[str, object]
reason: str
@dataclass(frozen=True, slots=True)
class BatchConfirmDecision:
"""Host answer for one round's soft-confirm requests.
It covers the calls of that round only; the next round asks again. With
``each`` the host hands the calls back to the one-call prompt instead of
answering for the batch (``allowed``/``comment`` then carry no meaning).
"""
allowed: bool
comment: str | None = None
each: bool = False
# One prompt for a round's requests; a single request keeps ``ToolConfirmHook``.
type ToolBatchConfirmHook = Callable[
[Sequence[ToolConfirmRequest]],
BatchConfirmDecision | Awaitable[BatchConfirmDecision],
]
MIN_BATCH_CONFIRM_REQUESTS = 2
"""Below this many requests a round asks one call at a time (see ``prepare_batch_confirm``)."""
_PRIMITIVE_SCHEMA: dict[type, JSONSchema] = {
str: {"type": "string"},
int: {"type": "integer"},
@@ -46,13 +82,19 @@ class ToolTag(Flag):
class ToolDefinition:
"""A registered tool: schema for the model plus a callable handler."""
"""A registered tool: schema for the model plus a callable handler.
``writes`` names the arguments whose values name files the handler writes.
Calls of one batch that name the same file apply one at a time, in call
order, so the later one sees what the earlier one wrote.
"""
name: str
description: str
parameters: JSONSchema
handler: ToolHandler
tags: ToolTag
writes: tuple[str, ...]
def __init__(
self,
@@ -62,6 +104,7 @@ class ToolDefinition:
handler: ToolHandler,
*,
tags: ToolTag = ToolTag.LOCAL,
writes: tuple[str, ...] = (),
) -> None:
if not (tags & (ToolTag.LOCAL | ToolTag.PUBLIC)):
msg = f"tool {name!r} tags must include LOCAL and/or PUBLIC"
@@ -71,6 +114,7 @@ class ToolDefinition:
self.parameters = parameters
self.handler = handler
self.tags = tags
self.writes = writes
def to_tool_item(self) -> ToolFunctionItem:
return ToolFunctionItem(
@@ -130,6 +174,7 @@ def _build_definition(
name: str | None,
description: str | None,
tags: ToolTag,
writes: tuple[str, ...] = (),
) -> ToolDefinition:
tool_name = name or func.__name__
tool_description = description if description is not None else (inspect.getdoc(func) or "")
@@ -139,6 +184,7 @@ def _build_definition(
parameters=schema_from_callable(func),
handler=func,
tags=tags,
writes=writes,
)
@@ -162,6 +208,7 @@ def tool[**PS, R](
name: str | None = None,
description: str | None = None,
tags: ToolTag = ToolTag.LOCAL,
writes: tuple[str, ...] = (),
register: bool = True,
) -> Callable[[Callable[PS, R]], ToolDefinition]: ...
@@ -173,16 +220,19 @@ def tool[**PS, R](
name: str | None = None,
description: str | None = None,
tags: ToolTag = ToolTag.LOCAL,
writes: tuple[str, ...] = (),
register: bool = True,
) -> ToolDefinition | Callable[[Callable[PS, R]], ToolDefinition]:
"""Define (and by default catalog-register) a function as an agent tool.
Schema is inferred from type hints; description defaults to the docstring.
Default ``tags`` is :attr:`ToolTag.LOCAL`. When ``register`` is true, the
definition is added to the process :class:`~plyngent.tools.catalog.ToolCatalog`
with the current registration source (builtin unless a plugin context is set).
Catalog registration does **not** alone make the tool model-visible — hosts
still **select** tools into a :class:`ToolRegistry`.
Default ``tags`` is :attr:`ToolTag.LOCAL`. ``writes`` names the arguments
that carry the files the handler writes (:attr:`ToolDefinition.writes`).
When ``register`` is true, the definition is added to the process
:class:`~plyngent.tools.catalog.ToolCatalog` with the current registration
source (builtin unless a plugin context is set). Catalog registration does
**not** alone make the tool model-visible — hosts still **select** tools
into a :class:`ToolRegistry`.
"""
def decorator(fn: Callable[PS, R]) -> ToolDefinition:
@@ -191,6 +241,7 @@ def tool[**PS, R](
name=name,
description=description,
tags=tags,
writes=writes,
)
if register:
_register_definition(definition)
@@ -225,13 +276,44 @@ def _missing_required_error(definition: ToolDefinition, args: Mapping[str, objec
return f"error: `{definition.name}` is missing required argument(s): {', '.join(missing)}{hint}"
def _decode_tool_args(arguments_json: str) -> dict[str, object] | str:
"""Decode one call's arguments; a returned string is the model-facing error."""
try:
raw_args: object = msgspec.json.decode(arguments_json.encode())
except (msgspec.DecodeError, UnicodeEncodeError) as exc:
return f"error: invalid tool arguments JSON: {exc}"
if not isinstance(raw_args, dict):
return "error: tool arguments must be a JSON object"
return {str(key): value for key, value in cast("dict[object, object]", raw_args).items()}
def _denied_error(name: str, reason: str, comment: str | None) -> str:
"""Model-facing text for a denied soft confirm (carries a user comment if one was given)."""
if comment is not None and comment.strip():
return f"error: tool {name!r} denied by user confirm ({reason}); user comment: {comment.strip()}"
return f"error: tool {name!r} denied by user confirm ({reason})"
def _write_path_key(value: str) -> str:
"""Comparable key for one path argument of a writing tool.
Relative arguments all mean the same workspace, so the text itself separates
two spellings of one file (``a.txt`` / ``./a.txt``) from two files.
"""
return str(Path(value).expanduser())
class ToolRegistry:
"""Name → tool definition map with execution helpers."""
_tools: dict[str, ToolDefinition]
_danger: DangerClassifier | None
_on_confirm: ToolConfirmHook | None
_on_confirm_batch: ToolBatchConfirmHook | None
_confirm_lock: asyncio.Lock
# One lock per written file, kept for the registry's life (a session's edits
# bound the map), so a batch's writes to one file apply one at a time.
_write_locks: dict[str, asyncio.Lock]
_yolo: bool
_auto_bind_state: bool
_instance: object | None
@@ -243,6 +325,7 @@ class ToolRegistry:
*,
danger: DangerClassifier | None = None,
on_confirm: ToolConfirmHook | None = None,
on_confirm_batch: ToolBatchConfirmHook | None = None,
yolo: bool = False,
auto_bind_state: bool = False,
instance_state: object | None = None,
@@ -251,7 +334,9 @@ class ToolRegistry:
self._tools = {}
self._danger = danger
self._on_confirm = on_confirm
self._on_confirm_batch = on_confirm_batch
self._confirm_lock = asyncio.Lock()
self._write_locks = {}
self._yolo = yolo
self._auto_bind_state = auto_bind_state
self._instance = instance_state
@@ -309,6 +394,7 @@ class ToolRegistry:
definitions,
danger=self._danger,
on_confirm=self._on_confirm,
on_confirm_batch=self._on_confirm_batch,
yolo=self._yolo if yolo is None else yolo,
auto_bind_state=self._auto_bind_state if auto_bind_state is None else auto_bind_state,
instance_state=instance_state,
@@ -381,6 +467,25 @@ class ToolRegistry:
return cast("Any", self._session)
return session
def _write_locks_for(self, definition: ToolDefinition, args: Mapping[str, object]) -> list[asyncio.Lock]:
"""Locks for the files this call writes, in a stable order (no deadlock).
Empty for a tool that declares no :attr:`ToolDefinition.writes`.
"""
keys: set[str] = set()
for name in definition.writes:
value = args.get(name)
if isinstance(value, str) and value.strip():
keys.add(_write_path_key(value))
locks: list[asyncio.Lock] = []
for key in sorted(keys):
lock = self._write_locks.get(key)
if lock is None:
lock = asyncio.Lock()
self._write_locks[key] = lock
locks.append(lock)
return locks
def _grant_allows(self, name: str, *, tags: ToolTag) -> bool:
if not (tags & ToolTag.TRUSTABLE):
return False
@@ -414,9 +519,92 @@ class ToolRegistry:
if decision is True:
await self._record_grant(name, tags=tags)
return None
if isinstance(decision, str) and decision.strip():
return f"error: tool {name!r} denied by user confirm ({reason}); user comment: {decision.strip()}"
return f"error: tool {name!r} denied by user confirm ({reason})"
comment = decision if isinstance(decision, str) else None
return _denied_error(name, reason, comment)
async def _apply_batch_decision(
self,
name: str,
reason: str,
decision: BatchConfirmDecision,
*,
tags: ToolTag,
) -> str | None:
if decision.allowed:
await self._record_grant(name, tags=tags)
return None
return _denied_error(name, reason, decision.comment)
def _prepare_confirm(self, name: str, arguments_json: str) -> ToolConfirmRequest | None:
"""Soft-confirm request for one call, or ``None`` when it needs none.
Mirrors the front half of :meth:`execute`: a call whose arguments fail to
decode or miss a required argument is answered with an error there and
must not be put in front of the user now.
"""
definition = self._tools.get(name)
if self._danger is None or definition is None:
return None
args = _decode_tool_args(arguments_json)
if isinstance(args, str) or _missing_required_error(definition, args) is not None:
return None
reason = self._danger(name, args)
return None if reason is None else ToolConfirmRequest(name=name, args=args, reason=reason)
def _batch_requests(
self,
calls: Sequence[tuple[str, str]],
) -> tuple[list[ToolConfirmRequest], list[int]]:
"""Requests for a round, plus the index each one came from."""
requests: list[ToolConfirmRequest] = []
positions: list[int] = []
for index, (name, arguments_json) in enumerate(calls):
request = self._prepare_confirm(name, arguments_json)
if request is not None:
requests.append(request)
positions.append(index)
return requests, positions
async def _record_batch_allow(
self,
requests: Sequence[ToolConfirmRequest],
decision: BatchConfirmDecision,
) -> None:
"""Record the trust a batch allow carries (the trustable grants)."""
if decision.allowed:
for request in requests:
definition = self._tools.get(request.name)
if definition is not None:
await self._record_grant(request.name, tags=definition.tags)
async def prepare_batch_confirm(
self,
calls: Sequence[tuple[str, str]],
) -> list[BatchConfirmDecision | None]:
"""Ask once for every soft confirm a round's calls would raise.
Returns one entry per call: a decision to apply instead of prompting, or
``None`` to fall through to the per-call path. Fewer than
:data:`MIN_BATCH_CONFIRM_REQUESTS` requests keep the plain one-call
prompt, a host without a batch hook keeps the per-call prompts, and a
:attr:`BatchConfirmDecision.each` answer hands this round back to them.
"""
decisions: list[BatchConfirmDecision | None] = [None] * len(calls)
if self._danger is None or self._on_confirm_batch is None:
return decisions
requests, positions = self._batch_requests(calls)
if len(requests) < MIN_BATCH_CONFIRM_REQUESTS:
return decisions
async with self._confirm_lock:
decision = self._on_confirm_batch(requests)
if inspect.isawaitable(decision):
decision = await decision
if decision.each:
return decisions
await self._record_batch_allow(requests, decision)
for index in positions:
decisions[index] = decision
return decisions
async def _maybe_confirm(
self,
@@ -424,12 +612,14 @@ class ToolRegistry:
args: dict[str, object],
*,
tags: ToolTag,
decision: BatchConfirmDecision | None = None,
) -> str | None:
"""Soft-confirm gate driven by danger reason + tags + grants.
Pipeline (soft gate only; hard denylists stay in tool handlers)::
no soft reason → run
batch decision for this call → apply it
(tags & TRUSTABLE) and grant exists → allow
else → on_confirm (decorator YOLO tag does not override
dangerous invocations — internal danger classification
@@ -442,22 +632,31 @@ class ToolRegistry:
return None
async with self._confirm_lock:
if decision is not None:
return await self._apply_batch_decision(name, reason, decision, tags=tags)
if self._grant_allows(name, tags=tags):
return None
return await self._prompt_soft_confirm(name, args, reason, tags=tags)
async def execute(self, name: str, arguments_json: str) -> str:
"""Run a tool by name; returns a string result (errors become error text)."""
async def execute(
self,
name: str,
arguments_json: str,
*,
decision: BatchConfirmDecision | None = None,
) -> str:
"""Run a tool by name; returns a string result (errors become error text).
*decision* is the host's answer for this call from
:meth:`prepare_batch_confirm`; ``None`` means the call was not part of an
aggregated prompt and confirms on its own.
"""
definition = self._tools.get(name)
if definition is None:
return f"error: unknown tool {name!r}"
try:
raw_args: object = msgspec.json.decode(arguments_json.encode())
except (msgspec.DecodeError, UnicodeEncodeError) as exc:
return f"error: invalid tool arguments JSON: {exc}"
if not isinstance(raw_args, dict):
return "error: tool arguments must be a JSON object"
args = {str(key): value for key, value in cast("dict[object, object]", raw_args).items()}
args = _decode_tool_args(arguments_json)
if isinstance(args, str):
return args
# Argument shape/coverage problems are answered before any confirm prompt:
# the human should never be asked about a call that cannot run.
missing_error = _missing_required_error(definition, args)
@@ -468,10 +667,15 @@ class ToolRegistry:
missing = self._check_state_tags(definition)
if missing is not None:
return missing
denied = await self._maybe_confirm(name, args, tags=definition.tags)
if denied is not None:
return denied
return await self._invoke(definition, args)
# Held before the confirm prompt, so a sibling call writing the same
# file waits for this one instead of overtaking its prompt.
async with AsyncExitStack() as held:
for lock in self._write_locks_for(definition, args):
await held.enter_async_context(lock)
denied = await self._maybe_confirm(name, args, tags=definition.tags, decision=decision)
if denied is not None:
return denied
return await self._invoke(definition, args)
# Bind host-provided state for tools that read contextvars (todo, workspace).
# Tag enforcement remains gated by ``auto_bind_state``; binding is always useful
+1 -1
View File
@@ -341,7 +341,7 @@ async def _run_chat( # noqa: C901, PLR0915 — chat orchestration
state.instance_state.workspace.path_denylist = tuple(store.agent_config.path_denylist or ())
from plyngent.tools.net import set_ssrf_assume_public_cidrs
# Clash Fake-IP etc.: treat configured CIDRs as public for fetch SSRF.
# Clash Fake-IP etc.: treat configured CIDRs as public for fetch_local SSRF.
_ = set_ssrf_assume_public_cidrs(store.networking_config.ssrf_assume_public_cidrs)
if interactive:
from plyngent.cli.limits import prompt_policy_command_confirm, prompt_policy_fetch_confirm
+78 -6
View File
@@ -21,6 +21,7 @@ from plyngent.agent import (
TextDeltaEvent,
ToolCallEvent,
ToolResultEvent,
TruncatedEvent,
UsageEvent,
)
from plyngent.lmproto.openai_compatible.model import AssistantFunctionToolCall
@@ -33,6 +34,9 @@ if TYPE_CHECKING:
_TOOL_RESULT_PREVIEW = 120
_TOOL_ARGS_PREVIEW = 80
_ASK_SUBJECT_PREVIEW = 60
# ``run_argv_batch`` lists the commands it runs above its ``* Run batch `` row — one
# line per command, a ``pipe_out`` chain joined into one — elided past this width.
_RUN_BATCH_CMD_LIMIT = 80
_MCP_TOOL_PREFIX = "mcp__"
_SKILL_MAIN_FILE = "SKILL.md"
@@ -117,8 +121,8 @@ def _json_str_arg(args_json: str, key: str) -> str | None:
return value if isinstance(value, str) else None
def _json_list_arg(args_json: str, key: str) -> list[str] | None:
value = _json_arg(args_json, key)
def _str_list(value: object) -> list[str] | None:
"""*value* as a list of strings, or None when it is not one."""
if isinstance(value, list):
raw_items = cast("list[object]", value)
items = [item for item in raw_items if isinstance(item, str)]
@@ -127,6 +131,10 @@ def _json_list_arg(args_json: str, key: str) -> list[str] | None:
return None
def _json_list_arg(args_json: str, key: str) -> list[str] | None:
return _str_list(_json_arg(args_json, key))
def _json_int_arg(args_json: str, key: str) -> int | None:
value = _json_arg(args_json, key)
if isinstance(value, bool):
@@ -387,8 +395,42 @@ def _run_argv_detail(_args_json: str, result: str) -> str:
return _pretty_segments((f"(exit code {code or 'killed'})", fg))
def _run_argv_batch_prefix(_args_json: str) -> str:
return _pretty_prefix("* Run batch ")
def _batch_steps(args_json: str) -> list[dict[str, object]]:
"""Steps of a batch call: a JSON array, or a JSON string holding one."""
value = _json_arg(args_json, "steps")
if isinstance(value, str):
try:
value = json.loads(value)
except ValueError:
return []
if not isinstance(value, list):
return []
raw_steps = cast("list[object]", value)
return [cast("dict[str, object]", step) for step in raw_steps if isinstance(step, dict)]
def _batch_command_lines(args_json: str) -> list[str]:
"""One command per line, a ``pipe_out`` chain joined into one line with ``|``."""
lines: list[str] = []
piped_from_previous = False
for step in _batch_steps(args_json):
argv = _str_list(step.get("argv"))
command = shlex.join(argv) if argv else "?"
if piped_from_previous and lines:
lines[-1] = f"{lines[-1]} | {command}"
else:
lines.append(command)
# Same flag spellings the tool itself accepts (bool, or "true"/"1"/…).
piped_from_previous = str(step.get("pipe_out") or "").strip().lower() in {"1", "true", "yes", "on"}
return lines
def _run_argv_batch_prefix(args_json: str) -> str:
"""``* Run batch `` with the commands it runs listed above it."""
commands = _batch_command_lines(args_json)
body = "\n".join(_pretty_prefix(f"$ {_preview(command, _RUN_BATCH_CMD_LIMIT)}") for command in commands)
header = _pretty_prefix("* Run batch ")
return f"{body}\n{header}" if body else header
def _run_argv_batch_detail(_args_json: str, result: str) -> str:
@@ -410,13 +452,34 @@ def _http_status_fg(status: str) -> str | None:
def _fetch_prefix(args_json: str) -> str:
"""Remote ``fetch``: the URL is the whole call."""
url = _json_str_arg(args_json, "url") or "?"
return _pretty_prefix(f"* Fetch {url} ")
def _fetch_detail(args_json: str, result: str) -> str:
"""Remote ``fetch`` outcome: fetched size, or an error.
A row written before the local tool was renamed still carries the local
``status=...`` block; render it with the local formatter.
"""
if result.startswith("error:"):
return _pretty_segments((f"({result})", "red"))
fields = _status_fields(result)
if "status" in fields:
return _fetch_local_detail(args_json, result)
chars = fields.get("chars", "")
return _pretty_segments((f"({chars} chars)" if chars else "(done)", "green"))
def _fetch_local_prefix(args_json: str) -> str:
method = _json_str_arg(args_json, "method") or "GET"
url = _json_str_arg(args_json, "url") or "?"
return _pretty_prefix(f"* Fetch {method} {url} ")
def _fetch_detail(_args_json: str, result: str) -> str:
"""``fetch`` outcome: HTTP status, or error."""
def _fetch_local_detail(_args_json: str, result: str) -> str:
"""``fetch_local`` outcome: HTTP status, or error."""
if result.startswith("error:"):
return _pretty_segments((f"({result})", "red"))
status = _status_fields(result).get("status", "")
@@ -846,6 +909,7 @@ _PRETTY_LINES: dict[str, PrettyLine] = {
"run_argv": PrettyLine(prefix=_run_argv_prefix, detail=_run_argv_detail),
"run_argv_batch": PrettyLine(prefix=_run_argv_batch_prefix, detail=_run_argv_batch_detail),
"fetch": PrettyLine(prefix=_fetch_prefix, detail=_fetch_detail),
"fetch_local": PrettyLine(prefix=_fetch_local_prefix, detail=_fetch_local_detail),
"web_search": PrettyLine(prefix=_web_search_prefix, detail=_web_search_detail),
"edit_replace": _mutator_line("edit_replace"),
"edit_lineno": _mutator_line("edit_lineno"),
@@ -1248,6 +1312,14 @@ async def render_events( # noqa: C901, PLR0915 — one branch per event type
)
else:
click.secho(f"\n[max rounds reached: {event.rounds}]", fg="red")
elif isinstance(event, TruncatedEvent):
flush_assistant()
tool_lines.close_line()
tail = "continuing" if event.continued else "/retry continues from here"
click.secho(
f"\n[limit] output truncated ({event.kept_chars} chars kept); {tail}",
fg="yellow",
)
elif isinstance(event, UsageEvent):
_ = event
else:
+25 -13
View File
@@ -112,11 +112,17 @@ def _run_blocking_editor(editor_cmd: str, path: Path) -> None:
raise click.ClickException(msg)
def _open_with_system_default(path: Path) -> None:
def _open_failure(reason: str, hint: str) -> str:
"""Compose an open failure message, appending the caller's fallback hint."""
return f"{reason}; {hint}" if hint else reason
def _open_with_system_default(path: Path, *, hint: str = "") -> None:
"""Open *path* with the OS file association (non-blocking).
Linux: ``xdg-open``; macOS: ``open``; Windows: ``os.startfile``.
Does not wait for the application to exit.
Does not wait for the application to exit. *hint* names the caller's own
fallback (an editor, a printed path) and is appended to failures.
"""
resolved = str(path.resolve())
if sys.platform == "win32":
@@ -141,19 +147,22 @@ def _open_with_system_default(path: Path) -> None:
stderr=subprocess.DEVNULL,
)
except FileNotFoundError as exc:
cmd = argv[0]
msg = (
f"{cmd} not found and neither VISUAL nor EDITOR is set; "
"set VISUAL or EDITOR to a blocking editor (e.g. nano, vim, codium --wait)"
)
raise click.ClickException(msg) from exc
raise click.ClickException(_open_failure(f"{argv[0]} not found", hint)) from exc
except OSError as exc:
msg = f"failed to open with system default: {exc}"
raise click.ClickException(msg) from exc
raise click.ClickException(_open_failure(f"failed to open with system default: {exc}", hint)) from exc
if completed.returncode != 0:
msg = f"system open failed (exit {completed.returncode}); set VISUAL or EDITOR to a blocking editor"
raise click.ClickException(msg)
raise click.ClickException(_open_failure(f"system open failed (exit {completed.returncode})", hint))
def open_directory(path: Path) -> None:
"""Reveal *path* (a directory) in the OS file manager (non-blocking).
A folder is not an editor target, so ``$VISUAL`` / ``$EDITOR`` are never
consulted: the platform opener decides. The caller prints *path* too, which
is the way in when the host has no opener at all.
"""
_open_with_system_default(path, hint="open the printed path manually")
def open_in_editor(
@@ -189,7 +198,10 @@ def open_in_editor(
msg = "neither VISUAL nor EDITOR is set"
raise click.ClickException(msg)
_open_with_system_default(path)
_open_with_system_default(
path,
hint="set VISUAL or EDITOR to a blocking editor (e.g. nano, vim, codium --wait)",
)
return "system"
+81 -12
View File
@@ -7,6 +7,7 @@ import sys
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
from plyngent.agent import BatchConfirmDecision, ToolConfirmRequest
from plyngent.cli.display import close_open_pretty_line
from plyngent.cli.interrupt import off_loop_prompt, pause_task_cancel_for_prompt
from plyngent.prompting import (
@@ -40,6 +41,11 @@ _CONTINUE_PROMPT = "Raise limit and continue? [Y/n/yyy] (yyy = stop asking this
_CONTINUE_YES_TOKENS = frozenset({"", "y", "yes"})
_CONTINUE_AUTO_TOKEN = "yyy"
# Batch confirm answers: ``y`` allows the round's calls, ``m`` reviews them one
# at a time through the per-call prompt.
_BATCH_YES_TOKENS = frozenset({"y", "yes"})
_BATCH_MANUAL_TOKENS = frozenset({"m", "manual", "each"})
@dataclass
class _AutoContinue:
@@ -105,18 +111,13 @@ def _wrap_line(text: str, width: int) -> list[str]:
return out
def format_tool_confirm_box(name: str, reason: str) -> str:
"""Multi-line boxed confirm body (header + reason lines).
Printed with :meth:`PromptBackend.echo` before a short ``confirm()`` prompt
so terminals keep newlines (unlike a single jammed readline line).
"""
def _box(header: str, lines: Sequence[str]) -> str:
"""Rounded box: a header row, a rule, then the wrapped body lines."""
term = _terminal_width()
inner = max(20, term - 4) # room for ``│ `` + `` │``
header = f"confirm · tool {name!r}"
body_lines: list[str] = []
for raw in reason.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
body_lines.extend(_wrap_line(raw, inner - _BOX_PAD))
for raw in lines:
body_lines.extend(_wrap_line(raw.replace("\r\n", "\n").replace("\r", "\n"), inner - _BOX_PAD))
content = [header, "─" * min(inner, max(len(header), 12)), *body_lines]
width = min(inner, max(len(line) for line in content) + _BOX_PAD)
width = max(width, min(inner, len(header) + _BOX_PAD))
@@ -134,6 +135,15 @@ def format_tool_confirm_box(name: str, reason: str) -> str:
return "\n".join(rows)
def format_tool_confirm_box(name: str, reason: str) -> str:
"""Multi-line boxed confirm body for one tool call.
Printed with :meth:`PromptBackend.echo` before a short ``confirm()`` prompt
so terminals keep newlines (unlike a single jammed readline line).
"""
return _box(f"confirm · tool {name!r}", reason.split("\n"))
def _echo_tool_confirm(name: str, reason: str) -> None:
backend = get_prompt_backend()
backend.echo()
@@ -311,14 +321,14 @@ def prompt_policy_fetch_confirm(
url: str,
timeout_seconds: float,
) -> bool:
"""Ask whether to allow fetch to a private/loopback host (timed).
"""Ask whether to allow fetch_local to a private/loopback host (timed).
Independent of YOLO: always prompts when interactive. Default is **deny**.
Timeout, cancel, or non-interactive → False. On approve, the host:port is
granted for the rest of this process (see net grants).
"""
reason = (
f"fetch targets private/loopback host {host!r} port {port}\n"
f"fetch_local targets private/loopback host {host!r} port {port}\n"
f"url: {url}\n"
f"Allow this host:port for the rest of this process?\n"
f"(timeout {timeout_seconds:g}s defaults to DENY; not skipped by YOLO)"
@@ -332,7 +342,7 @@ def prompt_policy_fetch_confirm(
backend.secho(format_tool_confirm_box("policy", reason), fg="yellow")
backend.echo()
grant = f"{host}:{port}"
prompt = f"[policy] allow fetch {grant!r}? [y/N] (timeout {timeout_seconds:g}s): "
prompt = f"[policy] allow fetch_local {grant!r}? [y/N] (timeout {timeout_seconds:g}s): "
with contextlib.suppress(OSError):
_ = sys.stderr.write(prompt)
_ = sys.stderr.flush()
@@ -482,6 +492,65 @@ async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reaso
return comment or False
async def prompt_confirm_batch_async(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
"""One confirm for one round's soft-confirm requests (``y`` / ``N`` / ``m``).
The answer covers exactly those calls — the next round asks again; ``m``
asks them one at a time instead, and a deny carries one comment shared by
every one of them.
"""
try:
from plyngent.prompting import run_prompt_async
return await run_prompt_async(lambda: _prompt_confirm_batch_sync(requests))
except NonInteractiveError:
return BatchConfirmDecision(allowed=False)
def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
backend = get_prompt_backend()
if not backend.is_interactive():
# Non-interactive: deny, like the default of the single prompt.
return BatchConfirmDecision(allowed=False)
backend.echo()
backend.secho(format_batch_confirm_box(requests), fg="yellow")
backend.secho("m = review them one call at a time", fg="bright_black")
backend.echo()
try:
token = (
backend.read_line(
f"Allow these {len(requests)} tool calls? [y/N/m]:",
default="n",
)
.strip()
.lower()
)
except NonInteractiveError:
return BatchConfirmDecision(allowed=False)
if token in _BATCH_MANUAL_TOKENS:
return BatchConfirmDecision(allowed=False, each=True)
if token in _BATCH_YES_TOKENS:
return BatchConfirmDecision(allowed=True)
try:
comment = ask(
"Optional comment for the agent (why denied; empty to skip):",
default="",
).strip()
except NonInteractiveError:
comment = ""
return BatchConfirmDecision(allowed=False, comment=comment or None)
def format_batch_confirm_box(requests: Sequence[ToolConfirmRequest]) -> str:
"""Boxed body listing every call a round wants to make, one numbered block each."""
lines: list[str] = []
for index, request in enumerate(requests, start=1):
reason_lines = request.reason.split("\n")
lines.append(f"{index}. [{request.name}] {reason_lines[0]}")
lines.extend(f" {line}" for line in reason_lines[1:])
return _box(f"confirm · {len(requests)} tool calls", lines)
def _prompt_workspace_mismatch_sync(
session_id: int,
session_workspace: str,
+50 -38
View File
@@ -18,6 +18,7 @@ if TYPE_CHECKING:
from plyngent.agent import ToolRegistry
from plyngent.cli.state import ReplState, YoloMode
from plyngent.skills import SkillStore
_COMPACT_PREVIEW = 400
_ON_OFF_CHOICES = ("on", "off")
@@ -419,12 +420,12 @@ def complete_slash_args(
if flags:
return sorted(set(flags))
# Contextual /todos second token: todo ids after done|cancel|pending|in_progress.
# Contextual /todos second token: listing numbers after done|cancel|…
if name == "todos" and prior:
action = prior[0].lower()
if action in {"done", "cancel", "pending", "in_progress"} and len(prior) == 1:
ids = [item.id for item in state.todo_stack.all_items()]
return [i for i in ids if i.startswith(incomplete)]
numbers = [str(n) for n in range(1, len(state.todo_stack.all_items()) + 1)]
return [n for n in numbers if n.startswith(incomplete)]
# Positional arguments: complete the next unset argument.
arg_params = [p for p in cmd.params if isinstance(p, click.Argument)]
@@ -478,14 +479,6 @@ slash.add_command(quit_cmd, name="exit")
slash.add_command(quit_cmd, name="q")
@slash.command("clear")
@click.pass_obj
def clear_cmd(state: ReplState) -> None:
"""Clear in-memory conversation (keeps session id)."""
state.agent.messages.clear()
click.echo("conversation cleared (in-memory only; DB history kept)")
@slash.command("edit")
@click.pass_obj
def edit_cmd(state: ReplState) -> None:
@@ -1252,30 +1245,50 @@ def mcp_slash_cmd(state: ReplState, action: str | None) -> None:
_print_mcp_status(state)
def _open_skill_directory(store: SkillStore, name: str) -> None:
"""Reveal a skill's directory in the host file manager (``/skills open``).
Reading a skill is a file-manager job, not a render of the model-facing
``skill_read`` output; the printed path is the fallback on a headless host.
"""
from plyngent.cli.editor import open_directory
skill = store.find(name)
if skill is None:
click.echo(f"error: unknown skill {name!r}; /skills lists the discovered ones")
return
try:
open_directory(skill.path)
except click.ClickException as exc:
click.echo(f"error: {exc}")
click.echo(f"skill directory: {skill.path}")
return
click.echo(f"opened {skill.path}")
@slash.command("skills")
@click.argument("action", required=False, type=click.Choice(["list", "search", "read", "reload"]))
@click.argument("action", required=False, type=click.Choice(["list", "search", "open"]))
@click.argument("target", required=False)
@click.option("--file", "file", default="", help="File inside the skill (with ``read``).")
@click.pass_obj
def skills_slash_cmd(state: ReplState, action: str | None, target: str | None, file: str) -> None:
"""List skills, search or read them, or rescan the roots.
def skills_slash_cmd(state: ReplState, action: str | None, target: str | None) -> None:
"""List skills, search them, or open one's directory.
``/skills`` or ``/skills list`` — discovered skills with their source root.
``/skills search PATTERN`` — regex over every skill's contents.
``/skills read NAME [--file F]`` — print a skill's SKILL.md (or one file).
``/skills reload`` — rescan the roots after an external edit.
``/skills open NAME`` — reveal the skill's directory in the file manager.
"""
from plyngent.tools.context import bind_tool_context
from plyngent.tools.skills import SKILL_TOOL_NAMES, format_skill_table, skill_read, skill_search
from plyngent.tools.skills import SKILL_TOOL_NAMES, format_skill_table, skill_search
if state.skills is None:
click.echo("skills: disabled (set [skills].enabled = true)")
return
act = (action or "list").lower()
if act == "reload":
state.reload_skills()
_slash_rebuild_tools_if_on(state)
click.echo(f"skills reloaded: {len(state.skills.skills)} visible; tools rebuilt")
if act == "open":
if not target:
msg = "/skills open requires a skill name"
raise click.UsageError(msg)
_open_skill_directory(state.skills, target)
return
# The tool handlers read the bound instance (the registry binds it for a
# model call; a slash command has to do it itself).
@@ -1286,12 +1299,6 @@ def skills_slash_cmd(state: ReplState, action: str | None, target: str | None, f
raise click.UsageError(msg)
click.echo(_await(skill_search.handler(target)))
return
if act == "read":
if not target:
msg = "/skills read requires a skill name"
raise click.UsageError(msg)
click.echo(_await(skill_read.handler(target, file or "SKILL.md")))
return
click.echo(format_skill_table(state.skills))
if not state.tools_enabled:
click.secho(
@@ -1517,10 +1524,10 @@ def todos_cmd( # noqa: C901, PLR0911, PLR0915 — one branch per /todos action
) -> None:
"""Show or edit the LIFO stack of **task groups**.
``/todos`` — list (top group first)
``/todos`` — list (top group first; items numbered as shown)
``/todos push T1; T2`` — one new group of siblings
``/todos pop`` — pop entire top group
``/todos done <id>`` / ``/todos cancel <id>`` — set status
``/todos done <number>`` / ``/todos cancel <number>`` — set status
``/todos clear`` — wipe stack
"""
from plyngent.agent.todo_stack import parse_push_titles
@@ -1545,8 +1552,8 @@ def todos_cmd( # noqa: C901, PLR0911, PLR0915 — one branch per /todos action
click.echo(f"error: {exc}")
return
_await(state.persist_todo_stack())
ids = ", ".join(i.id for i in group.items)
click.echo(f"pushed group depth={stack.depth} items=[{ids}]")
pushed = ", ".join(i.title for i in group.items)
click.echo(f"pushed group depth={stack.depth} items=[{pushed}]")
click.echo(stack.render())
return
if act == "pop":
@@ -1555,13 +1562,13 @@ def todos_cmd( # noqa: C901, PLR0911, PLR0915 — one branch per /todos action
click.echo("todo stack empty")
return
_await(state.persist_todo_stack())
titles = ", ".join(f"{i.id}:{i.title}" for i in group.items) or "(empty)"
click.echo(f"popped TOP group ({titles})")
popped = ", ".join(i.title for i in group.items) or "(empty)"
click.echo(f"popped TOP group ({popped})")
click.echo(stack.render())
return
if act in {"done", "cancel", "pending", "in_progress"}:
if not rest:
click.echo(f"error: usage: /todos {act} <id>")
click.echo(f"error: usage: /todos {act} <number>")
return
if act == "done":
new_status = "done"
@@ -1572,12 +1579,17 @@ def todos_cmd( # noqa: C901, PLR0911, PLR0915 — one branch per /todos action
else:
new_status = "in_progress"
try:
item = stack.update(rest[0], status=new_status)
except (KeyError, ValueError) as exc:
number = int(rest[0].strip())
except ValueError:
click.echo(f"error: /todos {act} takes an item number from the list (e.g. /todos {act} 2)")
return
try:
item = stack.update(number, status=new_status)
except ValueError as exc:
click.echo(f"error: {exc}")
return
_await(state.persist_todo_stack())
click.echo(f"updated {item.id} → {item.status}")
click.echo(f"updated {number} → {item.status}")
click.echo(stack.render())
return
if act == "clear":
+61 -51
View File
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Literal
from plyngent.agent import ChatAgent, ToolRegistry
from plyngent.agent.loop import DEFAULT_MAX_ROUNDS
from plyngent.agent.todo_stack import TodoStack
from plyngent.lmproto.deepseek.webtools import DeepseekWebToolsClient
if TYPE_CHECKING:
from datetime import datetime
@@ -25,7 +26,7 @@ from plyngent.cli.models_source import (
)
from plyngent.config import EffectiveProvider, ReasoningConfig, resolve_effective_provider, resolve_reasoning
from plyngent.memory.database.store import normalize_workspace
from plyngent.runtime import create_client
from plyngent.runtime import create_client, normalize_http_timeout
from plyngent.tools import InstanceState, SessionState
from plyngent.tools.view import MemoryViewStore, session_data_view
@@ -343,40 +344,10 @@ class ReplState:
self.instance_state.skills = self.skills
_ = set_static_read_roots([root.path for root in roots], instance=self.instance_state)
def reload_skills(self) -> None:
"""Rescan the roots (after an external edit or a config change)."""
if self.skills is not None:
self.skills.reload()
def _skills_text(self) -> str:
"""Fold the discovered skills into the system prompt (like MCP instructions).
Only names, sources, and descriptions: the bodies stay on disk so a turn
pays for the skill it actually reads. Notes and shadowed copies are left
to ``skill_list``, which the model can call when something looks off.
"""
cfg = self.config.skills_config
if self.skills is None or not self.tools_enabled or not cfg.inject_catalog:
return ""
skills = self.skills.skills
if not skills:
return ""
limit = max(0, cfg.max_catalog_skills)
shown = skills[:limit]
lines = [
"### Skills",
"Available skills (read a skill's SKILL.md with `skill_read` before following it):",
]
lines.extend(f"- {skill.name} ({skill.root.label}): {skill.description}" for skill in shown)
if len(skills) > len(shown):
lines.append(f"- … and {len(skills) - len(shown)} more; use `skill_search` or `skill_list`")
lines.append("Search all skills with `skill_search`; create or update one with `skill_create` / `skill_edit`.")
return "\n".join(lines)
def _tool_registry(self) -> ToolRegistry | None:
if not self.tools_enabled:
return None
from plyngent.cli.limits import prompt_confirm_tool_async
from plyngent.cli.limits import prompt_confirm_batch_async, prompt_confirm_tool_async
from plyngent.tools.catalog import register_builtin_tools
from plyngent.tools.danger import classify_danger
from plyngent.tools.plugins import load_plugin_tools
@@ -404,6 +375,7 @@ class ReplState:
tools,
danger=classify_danger,
on_confirm=prompt_confirm_tool_async,
on_confirm_batch=prompt_confirm_batch_async,
yolo=yolo,
auto_bind_state=True,
instance_state=self.instance_state,
@@ -412,20 +384,43 @@ class ReplState:
search_tool = self._web_search_tool()
if search_tool is not None:
registry.register(search_tool)
fetch_tool = self._remote_fetch_tool()
if fetch_tool is not None:
registry.register(fetch_tool)
return registry
def _deepseek_web_client(self) -> DeepseekWebToolsClient | None:
"""The Search/Fetch API client for the active provider (None otherwise)."""
from plyngent.config.routing import deepseek_api_base_url
from plyngent.lmproto.openai_compatible.config import OpenAIConfig
provider = self.effective_provider
if provider.preset != "deepseek":
return None
return DeepseekWebToolsClient(
OpenAIConfig(
access_key_or_token=provider.access_key_or_token,
base_url=deepseek_api_base_url(provider),
timeout=normalize_http_timeout(provider.timeout),
)
)
def _remote_fetch_tool(self) -> ToolDefinition | None:
"""``fetch`` through DeepSeek's Fetch API (DeepSeek providers only)."""
from plyngent.tools.net.fetch_remote import build_fetch_tool
client = self._deepseek_web_client()
if client is None:
return None
return build_fetch_tool(client)
def _web_search_tool(self) -> ToolDefinition | None:
"""Local ``web_search`` for a provider whose surface cannot host one.
The sources come from ``provider.search_sources``, in fallback order
(see :func:`~plyngent.config.routing.local_web_search`).
"""
from plyngent.config.routing import (
deepseek_search_base_url,
local_web_search,
)
from plyngent.lmproto.anthropic.config import AnthropicConfig
from plyngent.lmproto.deepseek.anthropic.search import DeepseekSearchClient
from plyngent.config.routing import local_web_search
from plyngent.tools.net.bing import BingSearchBackend
from plyngent.tools.net.search import (
DeepseekSearchBackend,
@@ -433,22 +428,15 @@ class ReplState:
)
provider = self.effective_provider
if not local_web_search(provider) or not self.model:
if not local_web_search(provider):
return None
backends: list[SearchBackend] = []
for source in provider.search_sources:
if source == "deepseek":
backends.append(
DeepseekSearchBackend(
DeepseekSearchClient(
AnthropicConfig(
api_key=provider.access_key_or_token,
base_url=deepseek_search_base_url(provider),
)
),
model=self.model,
)
)
client = self._deepseek_web_client()
if client is None:
continue
backends.append(DeepseekSearchBackend(client))
elif source == "bing":
backends.append(BingSearchBackend())
if not backends:
@@ -476,7 +464,6 @@ class ReplState:
system_prompt = compose_agent_system_content(
agent_cfg.system_prompt,
agent_cfg.tool_directives,
self._skills_text(),
mcp_text,
)
on_limit = prompt_continue_limit_async if (self.interactive_limits or auto_continue_enabled()) else None
@@ -820,6 +807,7 @@ class ReplState:
self.session_state = self._session_data_for_todo()
self.agent = self._make_agent()
self._bind_todo_tools()
await self._notice_skill_tools()
async def rename_current_session(self, name: str) -> SessionRow:
if self.session_id is None:
@@ -884,6 +872,7 @@ class ReplState:
await self.agent.load_history()
await self.load_todo_stack()
await self.load_access_grants()
await self._notice_skill_tools()
if restarted:
await self._notice_restart(last_active=row.updated_at)
@@ -907,12 +896,33 @@ class ReplState:
await self.agent.load_history()
await self.load_todo_stack()
await self.load_access_grants()
await self._notice_skill_tools()
if restarted:
# Before touch_session: the stamp should be the previous run's activity.
await self._notice_restart(last_active=latest.updated_at)
_ = await self.memory.touch_session(latest.sid)
return "resume"
async def _notice_skill_tools(self) -> None:
"""Teach the skill tools once per session — never what skills exist.
Instructions only: the listing stays behind ``skill_list`` /
``skill_search``, and the store reads the filesystem live, so a skill that
appears later in the session is found the same way. A session created
before this host pushed the note gets it on resume instead of a second copy.
"""
from plyngent.agent import has_notice, skill_tools_notice
from plyngent.cli.display import echo_notice
if self.skills is None or not self.tools_enabled:
return
notice = skill_tools_notice()
if has_notice(self.agent.messages, notice.kind):
return
_ = await self.agent.push_notice(notice)
if not self.quiet:
echo_notice(notice, err=True)
async def _notice_restart(self, *, last_active: datetime | None) -> None:
"""Tell the model — and the user — that this session outlived a restart."""
from plyngent.agent import resume_notice
+1 -1
View File
@@ -9,7 +9,7 @@ for every printed row (``-vv``).
Numbering is a **display ordinal** over the conversation rows (1-based, in the
order the session holds them): it is what ``/history`` prints and what
``/history --message N`` addresses. Database row numbers are deliberately never
shown or used — they are an implementation detail that shifts with ``/clear``.
shown or used — they are an implementation detail of the store.
Local-only rows (the injected system prompt) have no number.
"""
+40 -24
View File
@@ -9,7 +9,8 @@ from msgspec import Struct, field
# - "anthropic" → Anthropic Messages API (``POST /messages`` on
# ``https://api.deepseek.com/anthropic``)
type DeepSeekConvention = Literal["", "openai", "anthropic", "responses"]
# Sources the local ``web_search`` tool can use (see ``SearchBackend``).
# Sources the local ``web_search`` tool can use (see ``SearchBackend``):
# ``deepseek`` = the platform Search API, ``bing`` = Bing's RSS backstop.
type SearchSource = Literal["deepseek", "bing"]
# Static directory pre-allow mode for ``[agent].allow_paths`` (path → mode).
@@ -40,19 +41,23 @@ DEFAULT_TOOL_DIRECTIVES = """\
### Workspace
- Explore with `tree`/`listdir`/`glob_paths`/`regex_files`/`read_file` when unsure.
- Stay under the workspace (or a path from `new_temporary_workspace`). Respect path denylists.
- Need a path outside the workspace? Call `request_directory_access` \
(human approves; denylists apply) instead of retrying an escaped path.
- Need a path outside the workspace? Call `request_directory_access` with the \
`mode` the escaped-path error names (human approves; denylists apply) instead of \
retrying that path.
### Files
- Prefer file tools over shell. `edit_replace` fails usually means a bad match — \
fix `old_string` or `max_replaces`; shell workarounds rarely help.
- `write_file` refuses to replace an existing file (read it, or change it with \
`edit_replace` / `edit_lineno`); a full replacement needs `overwrite=true`, which asks the human.
- `edit_replace` defaults to the first match; if remaining matches are reported, \
raise `max_replaces` or narrow `old_string`.
- `edit_lineno` edits only lines you read via `read_file(path, with_lineno=true)`; \
re-read with line numbers after any `edit_*` / `write_file` / copy/move/delete, \
since line numbers go stale.
- `read_file` results start with a 1-based line range `L{begin}-{end}` (`offset` \
is 0-based); `with_lineno` shows per-line numbers instead.
is 0-based); `with_lineno` shows per-line numbers instead. `skill_read` slices a \
skill file the same way.
- `regex_files` takes a Python `re` regex for `pattern`, not a filename glob \
(`*.py` / `**/*.py` fail with `invalid regex`), and a literal file/dir `path` \
(no glob expansion, so `src/**/*.py` fails with `path does not exist`); there is \
@@ -66,17 +71,24 @@ with it to keep reading (chains through truncations).
a `-c` one-liner, a script (`python x.py`), or a bare interactive shell alike — and each \
confirm blocks the turn, so it costs time. Invoke the target command directly (argv) instead \
of wrapping it in `bash -c` / `python -c` / `node`, and reach for a dedicated tool \
(`read_file` / `edit_*` / `vcs_*` / `fetch` / …) when one already does the job.
(`read_file` / `edit_*` / `vcs_*` / `fetch` / `fetch_local` / …) when one already does the job.
- Several `run_argv` calls in one step may run in parallel; use `run_argv_batch` \
for ordered pipelines (`pipe_out` / `mix_stderr` as needed).
- Prefer `vcs_*` for status/diff/log/branch when enough.
### Network
- Prefer `fetch` (GET/POST/PUT/DELETE) for HTTP(S) docs/APIs over curl/wget via shell.
- Set `user_agent` (or a User-Agent header) when the remote expects a specific client; \
otherwise a small default is used. Never rely on shell to spoof identity.
- Private/loopback/LAN hosts need an explicit human policy allow (not skipped by YOLO).
- Prefer provider hosted search (e.g. web_search) for open-ended research; use `fetch` for known URLs.
- `fetch` reads a public page as clean Markdown through the provider's Fetch API \
(when available, e.g. DeepSeek); `fetch_local` is the raw HTTP client \
(GET/POST/PUT/DELETE) from this host for localhost/LAN, APIs, custom headers, and \
status/redirect inspection.
- `offset` (0-based) skips leading body characters to page through a large body; \
truncated bodies resume with `get_truncated` and are not re-requested.
- Set `user_agent` (or a User-Agent header) on `fetch_local` when the remote expects a \
specific client; otherwise a small default is used. Never rely on shell to spoof identity.
- Private/loopback/LAN hosts need an explicit human policy allow (not skipped by YOLO); \
this applies to `fetch_local`, since `fetch` leaves from the provider's network.
- Prefer provider hosted search (e.g. web_search) for open-ended research; use `fetch` \
or `fetch_local` for known URLs.
- Respect denials, size limits, and HTTPS→HTTP redirect blocks; do not re-fetch the same URL repeatedly.
### Humans & safety
@@ -93,6 +105,9 @@ otherwise a small default is used. Never rely on shell to spoof identity.
### Todos
- Use the todo stack for multi-step work (LIFO groups: push related items, finish/update, \
pop the group). Open items mean unfinished work.
- A listing numbers items in the order it shows them (TOP group first); those numbers are \
positions, not ids, and shift when the stack changes — `todo_update` takes the number from \
the latest listing.
- The stack does **not** auto-clear. When every task is done (all items done/cancelled), \
call `todo_clear` (or pop finished TOP groups) so no hygiene noise is left behind.
"""
@@ -223,7 +238,7 @@ class McpConfig(Struct, omit_defaults=True):
class NetworkingConfig(Struct, omit_defaults=True):
"""Host-side network policy for tools such as ``fetch``.
"""Host-side network policy for tools such as ``fetch_local``.
``ssrf_assume_public_cidrs``: CIDR strings whose resolved addresses are treated
as **public for SSRF policy** (skip private-host grants). Use for Clash/meta
@@ -251,17 +266,15 @@ class SkillsConfig(Struct, omit_defaults=True):
``include_project_roots``)
A skill found in several roots shadows the lower-priority copies: they stay
listed, never hidden. ``enabled = false`` registers no skill tools at all and
injects no catalog. Writing needs an explicit grant (a confirm per call, or
``allow_write`` for a standing one); reads need none.
listed, never hidden. ``enabled = false`` registers no skill tools at all.
Writing needs an explicit grant (a confirm per call, or ``allow_write`` for a
standing one); reads need none.
"""
enabled: bool = True
paths: list[str] = field(default_factory=list)
discover: list[SkillDiscoverySource] = field(default_factory=lambda: ["plyngent", "claude"])
include_project_roots: bool = True
inject_catalog: bool = True
max_catalog_skills: int = 50
allow_write: bool = False
@@ -379,10 +392,10 @@ def _default_deepseek_models() -> dict[str, ModelConfig]:
def _default_deepseek_search_sources() -> list[SearchSource]:
"""Sources for the local ``web_search`` tool, in fallback order.
DeepSeek's own index first (it is the provider's own search), Bing's RSS as
the backstop when a search fails or finds nothing.
DeepSeek's own index (the platform Search API) is the default. Bing's RSS is
a brittle, SEO-heavy backstop: list ``"bing"`` explicitly to opt in.
"""
return ["deepseek", "bing"]
return ["deepseek"]
class DeepseekProvider(ProviderConfig, tag="deepseek"):
@@ -395,11 +408,14 @@ class DeepseekProvider(ProviderConfig, tag="deepseek"):
``https://api.deepseek.com/anthropic``)
``provider_tools`` asks for a search tool like OpenAI's does (``[]``
disables it). DeepSeek runs a hosted search only on the Anthropic surface, so
the other conventions get a local ``web_search`` tool instead (see
``config.routing.local_web_search``); ``search_sources`` names what that tool
searches with, in fallback order — ``["deepseek", "bing"]`` by default,
``[]`` to not offer it.
disables the hosted one). DeepSeek hosts a search only on the Anthropic
surface; every other convention gets a local ``web_search`` tool instead
(see ``config.routing.local_web_search``). ``search_sources`` names what
that local tool searches with, in fallback order — ``["deepseek"]`` by
default (the platform Search API), ``[]`` to not offer it. ``"bing"`` adds
Bing's RSS as an opt-in backstop. The two settings are independent: turning
hosted tools off on the Anthropic convention leaves the local Search API tool
in place.
``extras`` keeps arbitrary provider keys; the legacy ``extras.convention``
key is parsed but ignored (prefer the typed ``convention`` field).
+24 -17
View File
@@ -98,27 +98,34 @@ def wants_web_search(effective: EffectiveProvider) -> bool:
def local_web_search(effective: EffectiveProvider) -> bool:
"""True when this provider searches through plyngent's own ``web_search`` tool.
DeepSeek runs a hosted search only on its Anthropic-compatible surface
(``convention = "anthropic"``): the Responses and chat-completions surfaces
ignore a hosted tool — a live probe answers "I can't browse the web" with
either ``{"type": "web_search"}`` or the server-tool shape — so a DeepSeek
provider that talks them searches through
:func:`~plyngent.tools.net.search.build_web_search_tool`, which asks the
sources named by ``search_sources`` behind the scenes.
``search_sources`` owns the local tool; ``provider_tools`` owns the hosted
one. DeepSeek's Anthropic-compatible surface can run the search inside a
response, so that combination keeps the hosted tool and the local one stays
out of the way; turning hosted tools off (``provider_tools = []``) brings
the local tool back there too. Every other surface ignores a hosted search,
so the local tool is the only one it can use.
"""
if effective.preset != "deepseek" or effective.convention == "anthropic":
if effective.preset != "deepseek" or not effective.search_sources:
return False
if not effective.search_sources:
return False
return wants_web_search(effective)
return not (effective.convention == "anthropic" and wants_web_search(effective))
def deepseek_search_base_url(effective: EffectiveProvider) -> str:
"""The endpoint a DeepSeek search turn runs on (its Anthropic-compatible base)."""
url = effective.url.rstrip("/")
if url.endswith("/anthropic"):
return url
return DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
def deepseek_api_base_url(effective: EffectiveProvider) -> str:
"""The platform base URL shared by model and tool APIs.
The Anthropic convention lives under ``/anthropic`` and OpenAI-compatible
configs often carry a ``/v1`` suffix; neither belongs in the base that the
Search API client appends ``/v1/tools/search`` to.
"""
url = (effective.url or DEFAULT_DEEPSEEK_BASE_URL).strip().rstrip("/")
stripped = True
while stripped:
stripped = False
for suffix in ("/anthropic", "/v1"):
if url.endswith(suffix):
url = url[: -len(suffix)].rstrip("/")
stripped = True
return url or DEFAULT_DEEPSEEK_BASE_URL
def resolve_effective_provider(provider: Provider, *, model: str | None = None) -> EffectiveProvider:
+13
View File
@@ -36,6 +36,10 @@ class AnthropicClient:
models_decoder: msgspec.json.Decoder[AnthropicModelsResponse]
# Explicit ``thinking`` budget for this client (0 = derive from the effort).
thinking_budget_tokens: int = 0
# Per model id, the ``thinking.type`` values the API refused this session; the
# dispatch retries with the shape the model takes instead (see
# ``agent.messages_dispatch``).
refused_thinking_types: dict[str, set[str]]
_api_key: str
_api_version: str
@@ -46,9 +50,18 @@ class AnthropicClient:
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.refused_thinking_types = {}
self._api_key = config.api_key
self._api_version = config.anthropic_version
def refuses_thinking_type(self, model: str, thinking_type: str) -> bool:
"""Whether the API already refused *thinking_type* for *model* this session."""
return thinking_type in self.refused_thinking_types.get(model, ())
def note_refused_thinking_type(self, model: str, thinking_type: str) -> None:
"""Remember that the API refuses *thinking_type* for *model*, saving the retry."""
self.refused_thinking_types.setdefault(model, set()).add(thinking_type)
def _headers(self) -> dict[str, str]:
return {
"Content-Type": "application/json",
+41 -6
View File
@@ -39,7 +39,11 @@ class AnthropicUserMessage(Struct, tag_field="role", tag="user"):
class AnthropicAssistantMessage(Struct, tag_field="role", tag="assistant"):
content: str | list[AnthropicTextContent | AnthropicToolUseContent]
# ``dict`` entries are provider-opaque blocks echoed back verbatim: a
# ``thinking`` block (its ``signature`` cannot be rebuilt from the text) and
# the ``server_tool_use`` + ``web_search_tool_result`` pair a hosted search
# answers inside the turn.
content: str | list[AnthropicTextContent | AnthropicToolUseContent | dict[str, Any]]
type AnthropicMessage = AnthropicUserMessage | AnthropicAssistantMessage
@@ -62,21 +66,34 @@ class AnthropicMetadata(Struct, omit_defaults=True):
class AnthropicThinkingConfig(Struct, omit_defaults=True):
"""``thinking`` request block (extended thinking).
"""``thinking`` request block (extended or adaptive 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).
``enabled`` is 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). Models from Claude 4.7 on reject ``enabled`` and take
``adaptive`` instead, where the depth comes from ``output_config.effort``;
``disabled`` turns thinking off where the model allows it.
``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"]
type: Literal["enabled", "disabled", "adaptive"]
budget_tokens: int | Unset = UNSET
class AnthropicOutputConfig(Struct, omit_defaults=True):
"""``output_config`` request block: how much the model spends on a turn.
``effort`` is the adaptive-thinking knob (``low`` … ``max``); it replaces a
``thinking.budget_tokens`` budget on the models that no longer take one.
"""
effort: str | Unset = UNSET
class AnthropicMessagesParam(Struct, omit_defaults=True):
model: str
max_tokens: int = 8192
@@ -93,6 +110,7 @@ class AnthropicMessagesParam(Struct, omit_defaults=True):
top_p: float | Unset = UNSET
top_k: int | Unset = UNSET
thinking: AnthropicThinkingConfig | Unset = UNSET
output_config: AnthropicOutputConfig | Unset = UNSET
class AnthropicUsage(Struct, omit_defaults=True):
@@ -125,11 +143,13 @@ class AnthropicServerToolUseContent(Struct, omit_defaults=True, tag_field="type"
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).
``caller`` is the surface's own metadata and goes back with the block.
"""
id: str = ""
name: str = ""
input: dict[str, Any] = field(default_factory=dict)
caller: dict[str, Any] | Unset = UNSET
class AnthropicWebSearchToolResultContent(Struct, omit_defaults=True, tag_field="type", tag="web_search_tool_result"):
@@ -166,12 +186,27 @@ class AnthropicMessageResponse(Struct, omit_defaults=True):
class AnthropicRawContentBlock(Struct, omit_defaults=True):
"""One streamed content block: the ``content_block`` / ``delta`` payload.
The fields cover the response union: ``text`` fragments, ``thinking`` and
its opaque ``signature``, a ``server_tool_use``'s ``input`` (streamed as
``partial_json``), and a ``web_search_tool_result``'s ``tool_use_id`` +
``content`` (kept loose — an entry's ``encrypted_content`` goes back
unchanged). ``agent.messages_dispatch`` rebuilds the blocks from these to
hand them to the next request.
"""
type: str = ""
id: str | None = None
name: str | None = None
text: str | None = None
input: dict[str, Any] | None = None
partial_json: str | None = None
thinking: str | None = None
signature: str | None = None
tool_use_id: str | None = None
content: list[dict[str, Any]] | None = None
caller: dict[str, Any] | None = None
class AnthropicMessageStart(Struct, tag_field="type", tag="message_start"):
@@ -1,148 +0,0 @@
"""Server-side web search on DeepSeek's Anthropic-compatible endpoint.
DeepSeek runs ``web_search`` *inside* a turn: the request carries the server
tool, and the response comes back with a ``server_tool_use`` block per query
plus the ``web_search_tool_result`` that query produced — nothing is executed
here. Results only carry a title, a URL and an ``encrypted_content`` the index
keeps the page body in, which is why the hit list is all a caller can pass on.
Only this surface searches: the Responses and chat-completions surfaces ignore a
hosted search tool (a live probe answers "I can't browse the web" whether the
tool arrives as ``{"type": "web_search"}`` or in the server-tool shape), so a
DeepSeek provider that talks them searches through
:func:`~plyngent.tools.net.search.build_web_search_tool` instead.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
import msgspec
import niquests
if TYPE_CHECKING:
from plyngent.lmproto.anthropic.config import AnthropicConfig
# A search turn stops as soon as the search came back: the answer that follows is
# thrown away, so the budget only has to cover the thinking that picks the query
# and the results. A live probe with 64 tokens spent all of it before searching
# and returned no results at all.
DEFAULT_SEARCH_MAX_TOKENS = 512
DEFAULT_SEARCH_MAX_USES = 3
_HTTP_ERROR = 400
_SEARCH_TOOL: dict[str, Any] = {"type": "web_search_20250305", "name": "web_search"}
class SearchHit(msgspec.Struct, frozen=True, omit_defaults=True):
"""One page a search source matched.
DeepSeek's index keeps the page bodies server-side, so its hits carry a
title and a URL only; a source that ships snippets (Bing's RSS) fills
``snippet`` too.
"""
title: str = ""
url: str = ""
snippet: str = ""
def hits_from_content(content: object) -> list[SearchHit]:
"""Collect the search hits from a response's content blocks (deduplicated)."""
if not isinstance(content, list):
return []
hits: list[SearchHit] = []
seen: set[str] = set()
for block_obj in cast("list[object]", content):
if not isinstance(block_obj, dict):
continue
block = cast("dict[str, object]", block_obj)
if block.get("type") != "web_search_tool_result":
continue
entries = block.get("content")
if not isinstance(entries, list):
continue
for entry_obj in cast("list[object]", entries):
if not isinstance(entry_obj, dict):
continue
entry = cast("dict[str, object]", entry_obj)
if entry.get("type") != "web_search_result":
continue
url = entry.get("url")
if not isinstance(url, str) or not url or url in seen:
continue
seen.add(url)
title = entry.get("title")
hits.append(SearchHit(title=title if isinstance(title, str) else "", url=url))
return hits
class DeepseekSearchClient:
"""``POST /messages`` with a hosted ``web_search`` tool, read for its hits."""
session: niquests.AsyncSession
encoder: msgspec.json.Encoder
_api_key: str
_api_version: str
def __init__(self, config: AnthropicConfig) -> None:
self.session = niquests.AsyncSession(base_url=config.base_url, timeout=config.timeout)
self.encoder = msgspec.json.Encoder()
self._api_key = config.api_key
self._api_version = config.anthropic_version
def _headers(self) -> dict[str, str]:
return {
"Content-Type": "application/json",
"x-api-key": self._api_key,
"anthropic-version": self._api_version,
}
async def search(
self,
query: str,
*,
model: str,
max_uses: int = DEFAULT_SEARCH_MAX_USES,
max_tokens: int = DEFAULT_SEARCH_MAX_TOKENS,
) -> list[SearchHit]:
"""Run *query* through the server's search; return the pages it matched."""
payload: dict[str, Any] = {
"model": model,
"max_tokens": max_tokens,
"messages": [{"role": "user", "content": query}],
"tools": [{**_SEARCH_TOOL, "max_uses": max_uses}],
}
resp = await self.session.post(
"/messages",
data=self.encoder.encode(payload),
headers=self._headers(),
stream=False,
)
body = await self._read_body(resp)
parsed = msgspec.json.decode(body, type=dict[str, object])
return hits_from_content(parsed.get("content"))
async def _read_body(self, resp: object) -> bytes:
from plyngent.lmproto.openai_compatible.client import (
http_error_message,
read_response_body,
)
body = await read_response_body(resp)
status = int(getattr(resp, "status_code", 0) or 0)
if status >= _HTTP_ERROR:
detail = http_error_message(status, body, what="search") or f"search HTTP {status}"
raise RuntimeError(detail)
if body is None:
msg = "search response body is empty"
raise RuntimeError(msg)
return body.encode() if isinstance(body, str) else bytes(body)
__all__ = [
"DEFAULT_SEARCH_MAX_TOKENS",
"DEFAULT_SEARCH_MAX_USES",
"DeepseekSearchClient",
"SearchHit",
"hits_from_content",
]
@@ -0,0 +1,12 @@
from .client import DeepseekWebToolsClient as DeepseekWebToolsClient
from .model import DEFAULT_SEARCH_COUNT as DEFAULT_SEARCH_COUNT
from .model import FETCH_PATH as FETCH_PATH
from .model import MAX_FETCH_URL_CHARS as MAX_FETCH_URL_CHARS
from .model import MAX_QUERY_CHARS as MAX_QUERY_CHARS
from .model import MAX_SEARCH_COUNT as MAX_SEARCH_COUNT
from .model import SEARCH_PATH as SEARCH_PATH
from .model import FetchRequest as FetchRequest
from .model import FetchResponse as FetchResponse
from .model import SearchRequest as SearchRequest
from .model import SearchResponse as SearchResponse
from .model import SearchResult as SearchResult
@@ -0,0 +1,194 @@
"""DeepSeek's Search and Fetch APIs: plain JSON, one bearer key.
``POST /v1/tools/search`` answers a query in one request, without spending a
model round — the endpoint replaces the older server-side search turn, which
cost a whole completion round (and its input tokens) just to pick the query and
could only return titles and URLs. A Search response already carries a snippet
and a model-facing summary, so the local ``web_search`` tool can hand the model
something readable on the first call.
``POST /v1/tools/fetch`` reads one public page and returns its title and content
as Markdown, for the remote ``fetch`` tool. Both endpoints share the session,
authentication, error shape, retry policy and rate limits.
``count`` does not affect billing (one successful request costs the same for 1
or 50 results); only successful requests are billable, so the retries below are
safe. The account-level limit is 5 QPS and is shared across the Search and Fetch
APIs independently of model concurrency; Fetch also has a per-domain limit.
"""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
from urllib.parse import urlsplit
import msgspec
import niquests
from niquests.auth import BearerTokenAuth
from plyngent.lmproto.openai_compatible.client import (
http_error_message,
read_response_body,
)
from plyngent.lmproto.search import SearchHit
from .model import (
DEFAULT_SEARCH_COUNT,
FETCH_PATH,
MAX_FETCH_URL_CHARS,
MAX_QUERY_CHARS,
MAX_SEARCH_COUNT,
SEARCH_PATH,
FetchRequest,
FetchResponse,
SearchRequest,
SearchResponse,
SearchResult,
)
if TYPE_CHECKING:
from plyngent.lmproto.openai_compatible.config import OpenAIConfig
_HTTP_ERROR = 400
_SERVER_ERROR = 500
# A response is retried when the server (or the account's QPS window) is the
# problem; a request-shaped error is never retried.
_RETRYABLE_STATUS = frozenset({408, 429})
RETRY_DELAYS: tuple[float, ...] = (0.5, 1.0)
_SNIPPET_FALLBACK_CHARS = 200
class DeepseekWebToolsClient:
"""``POST /v1/tools/search`` and ``/v1/tools/fetch`` on one session."""
session: niquests.AsyncSession
encoder: msgspec.json.Encoder
decoder: msgspec.json.Decoder[SearchResponse]
fetch_decoder: msgspec.json.Decoder[FetchResponse]
def __init__(self, config: OpenAIConfig) -> None:
self.session = niquests.AsyncSession(
base_url=config.base_url,
auth=BearerTokenAuth(config.access_key_or_token),
timeout=config.timeout,
)
self.encoder = msgspec.json.Encoder()
self.decoder = msgspec.json.Decoder(SearchResponse)
self.fetch_decoder = msgspec.json.Decoder(FetchResponse)
async def search(
self,
query: str,
*,
count: int = DEFAULT_SEARCH_COUNT,
need_content: bool = False,
) -> list[SearchHit]:
"""Run *query* and return the pages the index ranked.
The query is forwarded with its search syntax intact (``site:``,
``lang:``, ``after:``, quoted phrases, …), so the caller decides how
specific to be. ``need_content`` asks the index for page bodies; the
local tool leaves it off and reads a page with ``fetch`` instead.
"""
stripped = query.strip()
if not stripped:
msg = "search query must not be empty"
raise ValueError(msg)
if len(query) > MAX_QUERY_CHARS:
msg = f"search query must be at most {MAX_QUERY_CHARS} characters ({len(query)} given)"
raise ValueError(msg)
payload = SearchRequest(
query=query,
count=max(1, min(count, MAX_SEARCH_COUNT)),
need_content=need_content,
)
body = await self._post(SEARCH_PATH, self.encoder.encode(payload))
parsed = self.decoder.decode(body)
return [hit for hit in map(_to_hit, parsed.results) if hit.url]
async def fetch(self, url: str) -> FetchResponse:
"""Read *url* and return its title and Markdown content.
The page is fetched from DeepSeek's network, not from this host: local
SSRF checks do not apply, and neither do private-host grants — a LAN or
loopback target is simply unreachable there (the endpoint answers 403 or
404). The URL is still validated here so a malformed request fails
before it costs a round trip.
"""
_validate_fetch_url(url)
body = await self._post(FETCH_PATH, self.encoder.encode(FetchRequest(url=url)))
return self.fetch_decoder.decode(body)
async def _post(self, path: str, data: bytes) -> bytes:
"""POST *data*, retrying the statuses that mean "wait and try again"."""
for delay in RETRY_DELAYS:
status, body = await self._send(path, data)
if not _retryable(status):
return _require_body(status, body)
await asyncio.sleep(delay)
status, body = await self._send(path, data)
return _require_body(status, body)
async def _send(self, path: str, data: bytes) -> tuple[int, bytes | str | None]:
resp = await self.session.post(
path,
data=data,
headers={"Content-Type": "application/json"},
stream=False,
)
body = await read_response_body(resp)
return int(getattr(resp, "status_code", 0) or 0), body
def _retryable(status: int) -> bool:
return status in _RETRYABLE_STATUS or status >= _SERVER_ERROR
def _require_body(status: int, body: bytes | str | None) -> bytes:
"""Return a successful body; raise the endpoint's error otherwise."""
if status >= _HTTP_ERROR:
detail = http_error_message(status, body, what="search") or f"search HTTP {status}"
raise RuntimeError(detail)
if body is None:
msg = "search response body is empty"
raise RuntimeError(msg)
return body.encode() if isinstance(body, str) else bytes(body)
def _to_hit(result: SearchResult) -> SearchHit:
snippet = _collapse(result.snippet)
if not snippet:
snippet = _shorten(_collapse(result.summary))
return SearchHit(title=result.title.strip(), url=result.url.strip(), snippet=snippet)
def _collapse(text: str) -> str:
return " ".join(text.split())
def _shorten(text: str) -> str:
if len(text) <= _SNIPPET_FALLBACK_CHARS:
return text
return text[: _SNIPPET_FALLBACK_CHARS - 1].rstrip() + "…"
def _validate_fetch_url(url: str) -> None:
"""Reject a URL the endpoint would refuse, before paying for the request."""
if not url or len(url) > MAX_FETCH_URL_CHARS:
msg = f"fetch url must be 1-{MAX_FETCH_URL_CHARS} characters"
raise ValueError(msg)
try:
parts = urlsplit(url)
except ValueError:
msg = "fetch url is malformed"
raise ValueError(msg) from None
if parts.scheme not in {"http", "https"} or not parts.hostname:
msg = "fetch url must be an absolute http(s) URL with a host"
raise ValueError(msg)
if parts.username or parts.password:
msg = "fetch url must not carry a username or password"
raise ValueError(msg)
__all__ = ["RETRY_DELAYS", "DeepseekWebToolsClient"]
@@ -0,0 +1,68 @@
"""Wire models for DeepSeek's Search and Fetch APIs."""
from __future__ import annotations
import msgspec
SEARCH_PATH = "/v1/tools/search"
FETCH_PATH = "/v1/tools/fetch"
DEFAULT_SEARCH_COUNT = 10
MAX_SEARCH_COUNT = 50
MAX_QUERY_CHARS = 128
MAX_FETCH_URL_CHARS = 8192
class SearchRequest(msgspec.Struct, omit_defaults=True):
"""Body of one search request (unknown optional fields are left out)."""
query: str
count: int = DEFAULT_SEARCH_COUNT
ori_question: str | None = None
need_content: bool = False
class SearchResult(msgspec.Struct, omit_defaults=True):
"""One ranked result; unknown fields are ignored on decode."""
title: str = ""
url: str = ""
snippet: str = ""
# A 600-800 character summary "intended to be read by a model"; used as the
# snippet when the index has no short excerpt.
summary: str = ""
rerank_score: float = 0.0
authority: int = -1
class SearchResponse(msgspec.Struct):
"""Search results; empty when the query matched nothing (still HTTP 200)."""
results: list[SearchResult]
class FetchRequest(msgspec.Struct):
"""Body of one fetch request: a single absolute http(s) URL."""
url: str
class FetchResponse(msgspec.Struct):
"""A fetched page: its title and its content rendered as Markdown."""
title: str = ""
content: str = ""
__all__ = [
"DEFAULT_SEARCH_COUNT",
"FETCH_PATH",
"MAX_FETCH_URL_CHARS",
"MAX_QUERY_CHARS",
"MAX_SEARCH_COUNT",
"SEARCH_PATH",
"FetchRequest",
"FetchResponse",
"SearchRequest",
"SearchResponse",
"SearchResult",
]
@@ -5,8 +5,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
import msgspec
from msgspec import UNSET
from .model import DeveloperChatMessage, SystemChatMessage
from .model import AssistantChatMessage, DeveloperChatMessage, SystemChatMessage
if TYPE_CHECKING:
from collections.abc import Sequence
@@ -37,9 +38,31 @@ def coerce_developer_messages_to_system(messages: Sequence[AnyChatMessage]) -> l
return out if changed else list(messages)
def coerce_chat_messages(messages: Sequence[AnyChatMessage]) -> list[AnyChatMessage]:
"""Return *messages* as a chat-completions wire can take them.
Both normalizations are for broad provider compatibility: a ``developer``
role becomes ``system`` (see :func:`coerce_developer_messages_to_system`),
and ``AssistantChatMessage.provider_blocks`` is dropped — those blocks
belong to the surface that produced them (Anthropic ``thinking`` and
server-tool blocks; see ``agent.messages_bridge``) and no other provider
knows the key.
"""
coerced = coerce_developer_messages_to_system(messages)
changed = False
out: list[AnyChatMessage] = []
for message in coerced:
if isinstance(message, AssistantChatMessage) and message.provider_blocks is not UNSET:
out.append(msgspec.structs.replace(message, provider_blocks=UNSET))
changed = True
else:
out.append(message)
return out if changed else coerced
def coerce_chat_completions_param(param: ChatCompletionsParam) -> ChatCompletionsParam:
"""Normalize a chat-completions request for broad provider compatibility."""
messages = coerce_developer_messages_to_system(param.messages)
messages = coerce_chat_messages(param.messages)
if messages == param.messages:
return param
return msgspec.structs.replace(param, messages=messages)
@@ -56,7 +79,7 @@ def coerce_chat_completions_param_any(param: Any) -> Any:
if not isinstance(messages_obj, list):
return param
messages = cast("list[AnyChatMessage]", messages_obj)
coerced = coerce_developer_messages_to_system(messages)
coerced = coerce_chat_messages(messages)
if coerced == messages:
return param
return msgspec.structs.replace(param, messages=coerced)
@@ -74,6 +74,12 @@ class AssistantChatMessage(Struct, tag_field="role", tag="assistant"):
tool_calls: list[AnyAssistantToolCall] | Unset = UNSET
# OpenAI-compat / DeepSeek thinking streams (omitted when unset).
reasoning_content: str | Unset = UNSET
# Provider-opaque assistant blocks kept as raw JSON and echoed back verbatim
# by the surface that produced them (Anthropic: a ``thinking`` block, whose
# ``signature`` only the provider can verify, and the ``server_tool_use`` +
# ``web_search_tool_result`` pair a hosted search answers inside the turn).
# Surfaces that do not know them send the message without the field.
provider_blocks: list[dict[str, Any]] | Unset = UNSET
class ToolChatMessage(ChatMessage, tag_field="role", tag="tool"):
@@ -243,6 +249,10 @@ class DeltaMessage(Struct):
# ``response.reasoning_text.done``), replacing any streamed fragments.
reasoning_full: bool | Unset = UNSET
tool_calls: list[StreamToolCallDelta] | Unset = UNSET
# Provider-opaque blocks a bridge rebuilt mid-stream (see
# ``AssistantChatMessage.provider_blocks``): the loop copies them onto the
# round's assistant message.
provider_blocks: list[dict[str, Any]] | Unset = UNSET
class ChunkChoice(Struct):
+27
View File
@@ -0,0 +1,27 @@
"""Provider-neutral models shared by the local ``web_search`` sources.
A search source answers a query with a list of pages; the local tool in
``plyngent.tools.net.search`` formats them and the CLI wires whichever sources
``provider.search_sources`` names. Keeping the result type here (rather than in
one provider's package) lets the generic sources — Bing's RSS, DeepSeek's Search
API — speak the same shape without importing each other.
"""
from __future__ import annotations
import msgspec
class SearchHit(msgspec.Struct, frozen=True, omit_defaults=True):
"""One page a search source matched.
``snippet`` is whatever short excerpt the source ships: a real snippet from
Bing, or DeepSeek's ``summary`` when its index has no snippet for the page.
"""
title: str = ""
url: str = ""
snippet: str = ""
__all__ = ["SearchHit"]
+17 -37
View File
@@ -3,9 +3,8 @@
A skill is a directory holding a ``SKILL.md``. Roots are ordered by priority
(explicit ``[skills].paths`` first, then the built-in sources), so the first
skill with a given name wins and the copies it shadows stay visible for
reporting rather than disappearing. Parsed metadata is cached against the
directory list and its ``SKILL.md`` timestamps, which makes a listing cheap
while still noticing an edit made outside the chat.
reporting rather than disappearing. Nothing is cached: every lookup reads the
filesystem, so an edit or a new skill made outside the chat is simply there.
"""
from __future__ import annotations
@@ -40,7 +39,7 @@ class SkillRoot:
``origin`` names where the root came from (``config`` for an explicit path,
``plyngent`` for our own user directory, ``claude`` for another harness);
``scope`` is ``user`` or ``project``. Together they label a skill's
provenance in listings and in the injected catalog.
provenance in listings.
"""
path: Path
@@ -119,11 +118,10 @@ def _expand(token: str, *, home: Path | None) -> Path:
@dataclass(frozen=True, slots=True)
class _Entry:
"""One candidate skill directory plus the timestamp that invalidates the cache."""
"""One candidate skill directory under its root."""
root: SkillRoot
directory: Path
mtime_ns: int
def validate_skill_name(name: str) -> str | None:
@@ -166,14 +164,16 @@ def resolve_skill_file(skill: Skill, relative: str) -> Path | None:
class SkillStore:
"""Skills discovered under *roots*, highest-priority root first."""
"""Skills discovered under *roots*, highest-priority root first.
Each access re-reads the filesystem: the roots are a fixed list, the skills
under them are not.
"""
_roots: tuple[SkillRoot, ...]
_cache: tuple[tuple[tuple[str, int], ...], list[Skill]] | None
def __init__(self, roots: Sequence[SkillRoot]) -> None:
self._roots = tuple(roots)
self._cache = None
@property
def roots(self) -> tuple[SkillRoot, ...]:
@@ -182,20 +182,20 @@ class SkillStore:
@property
def all_skills(self) -> list[Skill]:
"""Every discovered skill, shadowed copies included (priority order)."""
return list(self._load())
return list(self._discover())
@property
def skills(self) -> list[Skill]:
"""Visible skills by name, sorted for display (shadows removed)."""
by_name: dict[str, Skill] = {}
for skill in self._load():
for skill in self._discover():
_ = by_name.setdefault(skill.name, skill)
return [by_name[name] for name in sorted(by_name)]
def shadowed(self) -> dict[str, list[Skill]]:
"""Skill names found in more than one root → the losing copies in order."""
found: dict[str, list[Skill]] = {}
for skill in self._load():
for skill in self._discover():
found.setdefault(skill.name, []).append(skill)
return {name: skills for name, skills in sorted(found.items()) if len(skills) > 1}
@@ -204,7 +204,7 @@ class SkillStore:
token = name.strip()
wanted = token.casefold()
fallback: Skill | None = None
for skill in self._load():
for skill in self._discover():
if skill.name == token:
return skill
if fallback is None and skill.name.casefold() == wanted:
@@ -218,25 +218,12 @@ class SkillStore:
return root
return self._roots[0] if self._roots else None
def reload(self) -> None:
"""Drop the cache so the next access rescans the filesystem."""
self._cache = None
def _load(self) -> list[Skill]:
entries = self._scan()
signature = tuple((str(entry.directory), entry.mtime_ns) for entry in entries)
if self._cache is not None and self._cache[0] == signature:
return self._cache[1]
skills = [skill for entry in entries if (skill := _read_skill(entry)) is not None]
self._cache = (signature, skills)
return skills
def _discover(self) -> list[Skill]:
"""Read every candidate ``SKILL.md`` now — nothing is cached."""
return [skill for entry in self._scan() if (skill := _read_skill(entry)) is not None]
def _scan(self) -> list[_Entry]:
return [
_Entry(root=root, directory=directory, mtime_ns=_skill_mtime(directory))
for root in self._roots
for directory in _skill_dirs(root.path)
]
return [_Entry(root=root, directory=directory) for root in self._roots for directory in _skill_dirs(root.path)]
def _skill_dirs(root: Path) -> list[Path]:
@@ -252,13 +239,6 @@ def _skill_dirs(root: Path) -> list[Path]:
return found
def _skill_mtime(directory: Path) -> int:
try:
return (directory / SKILL_FILE).stat().st_mtime_ns
except OSError:
return -1
def _read_skill(entry: _Entry) -> Skill | None:
"""Read one ``SKILL.md`` head and derive the record; ``None`` when unreadable."""
issues: list[str] = []
+1 -1
View File
@@ -46,7 +46,7 @@ from .file import write_file as write_file
from .net import NET_TOOLS as NET_TOOLS
from .net import clear_private_grants as clear_private_grants
from .net import clear_ssrf_assume_public_cidrs as clear_ssrf_assume_public_cidrs
from .net import fetch as fetch
from .net import fetch_local as fetch_local
from .net import grant_private_host as grant_private_host
from .net import set_fetch_policy_confirm_hook as set_fetch_policy_confirm_hook
from .net import set_ssrf_assume_public_cidrs as set_ssrf_assume_public_cidrs
+5 -5
View File
@@ -254,11 +254,11 @@ async def request_directory_access(path: str, reason: str = "", mode: str = "rea
"""Request human approval to use a directory (or exact file) outside the workspace.
Call this instead of retrying a path that failed with ``path escapes
workspace root``. ``mode`` is ``read`` (inspect files), ``write`` (also
modify files) or ``exec`` (also use it as ``run_argv`` / PTY cwd); the human
may approve a different level. Approval lasts for the session (or for this
process when ``--yes`` auto-approves). Paths matching the configured
denylist can never be granted.
workspace root``, passing the ``mode`` that error names. ``mode`` is ``read``
(inspect files), ``write`` (also modify files) or ``exec`` (also use it as
``run_argv`` / PTY cwd); the human may approve a different level. Approval
lasts for the session (or for this process when ``--yes`` auto-approves).
Paths matching the configured denylist can never be granted.
"""
requested = parse_access_mode(mode)
if requested is None:
+1 -1
View File
@@ -155,7 +155,7 @@ _read_only: ContextVar[bool] = ContextVar("plyngent_read_only_context", default=
def read_only_context() -> Generator[None]:
"""Mark the current context as read-only for tool execution.
Tools that may mutate depending on arguments (e.g. ``fetch`` with a
Tools that may mutate depending on arguments (e.g. ``fetch_local`` with a
non-GET method) check :func:`is_read_only_context` and refuse to mutate
while it is set.
"""
+11 -5
View File
@@ -87,7 +87,13 @@ def _command_reason(argv: Sequence[str], *, via: str) -> str | None:
def _write_file_reason(args: Mapping[str, object]) -> str | None:
"""Confirm only when write_file would replace an existing file."""
"""Confirm only when write_file was asked to replace an existing file.
An unset/``false`` ``overwrite`` never reaches this prompt: the tool itself
refuses an existing file, so there is nothing to ask about.
"""
if not bool(args.get("overwrite", False)):
return None
path = args.get("path")
if not isinstance(path, str) or not path:
return None
@@ -186,8 +192,8 @@ def _open_pty_reason(args: Mapping[str, object]) -> str | None:
def _fetch_reason(args: Mapping[str, object]) -> str | None:
"""Soft-confirm cleartext HTTP and mutating fetch methods (not private-host policy)."""
from plyngent.tools.net.fetch import fetch_soft_reason
"""Soft-confirm cleartext HTTP and mutating fetch_local methods (not private-host policy)."""
from plyngent.tools.net.fetch_local import fetch_soft_reason
method_obj = args.get("method", "GET")
url_obj = args.get("url", "")
@@ -205,7 +211,7 @@ def classify_danger(name: str, args: Mapping[str, object]) -> str | None: # noq
soft confirms for mutating tools and risky command launches — interpreters
(however invoked) and detached runners such as ``nohup``, resolved past
environment wrappers and ``<launcher> run``.
Private/loopback fetch targets use a separate policy grant (not YOLO).
Private/loopback fetch_local targets use a separate policy grant (not YOLO).
"""
if name == "delete_path":
return _delete_path_reason(args)
@@ -221,6 +227,6 @@ def classify_danger(name: str, args: Mapping[str, object]) -> str | None: # noq
return _run_argv_batch_reason(args)
if name == "open_pty":
return _open_pty_reason(args)
if name == "fetch":
if name == "fetch_local":
return _fetch_reason(args)
return None
+22 -4
View File
@@ -11,6 +11,9 @@ if TYPE_CHECKING:
# How many unread line numbers to preview in the error message.
_UNREAD_PREVIEW = 8
# Lines of context a suggested read covers on each side of the edit range.
_READ_HINT_MARGIN = 3
def _detect_newline(lines: list[str]) -> str:
for sample in lines:
@@ -68,6 +71,19 @@ def _unread_lines_in_range(
return sorted(set(range(start_line, end_line + 1)) - read_lines)
def _read_hint(path: str, start_line: int, end_line: int, n: int) -> str:
"""A ready-to-copy ``read_file`` call covering the edit range plus a margin.
``read_file`` counts ``offset`` from 0 while ``edit_lineno`` counts lines
from 1, so the call is spelled out instead of asking the model to convert.
The window is clipped to the file (``_validate_range`` already accepted the
edit) and the margin keeps neighbouring lines editable too.
"""
first = max(1, start_line - _READ_HINT_MARGIN)
last = max(first, min(n, end_line + _READ_HINT_MARGIN))
return f"read_file(path={path!r}, offset={first - 1}, limit={last - first + 1}, with_lineno=true)"
def _append_after(target: Path, path: str, text: str, lines: list[str], new_content: str) -> str:
n = len(lines)
newline = _detect_newline(lines[-1:] if lines else [])
@@ -108,7 +124,7 @@ def _replace_range(
)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("path",))
async def edit_lineno(path: str, start_line: int, end_line: int, new_content: str) -> str:
"""Replace lines ``start_line``..``end_line`` (1-based, inclusive) in a file.
@@ -135,8 +151,9 @@ async def edit_lineno(path: str, start_line: int, end_line: int, new_content: st
if read_lines:
window = f" (this turn read lines {min(read_lines)}-{max(read_lines)})"
return (
f"error: lines {head} not read{window}; call read_file(path, with_lineno=true) "
"first — read with a small margin around the lines you plan to edit"
f"error: lines {head} not read{window}; re-read with "
f"{_read_hint(path, start_line, end_line, len(lines))} "
"— offset is 0-based; the window keeps a margin around the edit"
)
recorded_mtime = lineno_read_mtime(str(target))
@@ -148,7 +165,8 @@ async def edit_lineno(path: str, start_line: int, end_line: int, new_content: st
if current_mtime is not None and current_mtime != recorded_mtime:
return (
f"error: {path} changed since it was read (line numbers may be stale); "
"re-read with read_file(path, with_lineno=true)"
f"re-read with {_read_hint(path, start_line, end_line, len(lines))} "
"— offset is 0-based"
)
if start_line == len(lines) + 1:
+1 -1
View File
@@ -48,7 +48,7 @@ def _success_message(
)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("path",))
async def edit_replace(path: str, old_string: str, new_string: str, max_replaces: int = 1) -> str:
"""Replace occurrences of ``old_string`` with ``new_string`` in a file.
+3 -3
View File
@@ -94,7 +94,7 @@ def _copy_or_move_validated(
return f"error: {action} failed: {exc}"
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("dst",))
async def copy_path(src: str, dst: str, *, overwrite: bool = False) -> str:
"""Copy a file or directory under the workspace (``shutil.copy2`` / ``copytree``).
@@ -110,7 +110,7 @@ async def copy_path(src: str, dst: str, *, overwrite: bool = False) -> str:
return _copy_or_move_validated(source, dest, root, src=src, dst=dst, overwrite=overwrite, action="copy")
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("src", "dst"))
async def move_path(src: str, dst: str, *, overwrite: bool = False) -> str:
"""Move/rename a file or directory under the workspace (``shutil.move``)."""
pair = _resolve_pair(src, dst)
@@ -149,7 +149,7 @@ def _delete_target(target: Path, path: str, *, recursive: bool) -> str:
return f"error: unsupported path type: {path}"
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("path",))
async def delete_path(path: str, *, recursive: bool = False) -> str:
"""Delete a file or directory under the workspace.
+9 -8
View File
@@ -60,14 +60,15 @@ async def get_truncated(token: str, *, max_chars: int = DEFAULT_TOOL_RESULT_MAX_
"""Fetch the next chunk of a truncated result using its ``truncate_token``.
Pass the token shown at the end of a truncated ``read_file``, ``fetch``,
``run_argv``, or earlier ``get_truncated`` result to keep reading without
re-requesting the whole source or raising limits. Truncated output carries a
fresh token, so chunks chain indefinitely. File chunks resume in the same
view as the original read: numbered reads continue as numbered lines
(editable via ``edit_lineno``), plain reads as raw text with a 1-based
``L{begin}-{end}`` range. Memory chunks (generic tool output, and ``fetch``
bodies) are served from a short-lived in-memory store that is forgotten when
the agent exits, so resuming a fetch never repeats the request.
``fetch_local``, ``run_argv``, or earlier ``get_truncated`` result to keep
reading without re-requesting the whole source or raising limits. Truncated
output carries a fresh token, so chunks chain indefinitely. File chunks
resume in the same view as the original read: numbered reads continue as
numbered lines (editable via ``edit_lineno``), plain reads as raw text with
a 1-based ``L{begin}-{end}`` range. Memory chunks (generic tool output, and
``fetch``/``fetch_local`` bodies) are served from a short-lived in-memory
store that is forgotten when the agent exits, so resuming a fetch never
repeats the request.
"""
parsed = decode_truncate_token(token)
if parsed is None:
+9 -3
View File
@@ -4,10 +4,16 @@ from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import AccessMode, resolve_path
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def write_file(path: str, content: str) -> str:
"""Write text content to a file under the workspace (creates parents)."""
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("path",))
async def write_file(path: str, content: str, *, overwrite: bool = False) -> str:
"""Write text content to a file under the workspace (creates parents).
Refuses to replace an existing file: pass ``overwrite=true`` to replace it
(the replacement is then confirmed by the human).
"""
target = resolve_path(path, required=AccessMode.WRITE)
if target.is_file() and not overwrite:
return f"error: file exists: {path} (set overwrite=true to replace it)"
target.parent.mkdir(parents=True, exist_ok=True)
_ = target.write_text(content, encoding="utf-8")
# The write changes the file's mtime, so edit_lineno's freshness check
+2 -2
View File
@@ -1,4 +1,4 @@
from .fetch import fetch as fetch
from .fetch_local import fetch_local as fetch_local
from .grants import clear_private_grants as clear_private_grants
from .grants import grant_private_host as grant_private_host
from .grants import set_fetch_policy_confirm_hook as set_fetch_policy_confirm_hook
@@ -6,5 +6,5 @@ from .policy import clear_ssrf_assume_public_cidrs as clear_ssrf_assume_public_c
from .policy import set_ssrf_assume_public_cidrs as set_ssrf_assume_public_cidrs
NET_TOOLS = [
fetch,
fetch_local,
]
+2 -2
View File
@@ -3,7 +3,7 @@
Bing answers a query with a plain RSS document (``?format=rss``): no key, no
page scraping, and each ``<item>`` already carries a title, a link and a
description (the snippet DeepSeek's own index does not hand out). The request
goes through the fetch client the ``fetch`` tool uses, so the SSRF and
goes through the fetch client the ``fetch_local`` tool uses, so the SSRF and
private-host grant policy applies to it as well.
"""
@@ -12,7 +12,7 @@ from __future__ import annotations
import urllib.parse
import xml.etree.ElementTree as ET
from plyngent.lmproto.deepseek.anthropic.search import SearchHit
from plyngent.lmproto.search import SearchHit
from plyngent.tools.net.client import http_fetch
SEARCH_URL = "https://www.bing.com/search"
@@ -1,4 +1,4 @@
"""Model-facing ``fetch`` tool (HTTP GET/POST/PUT/DELETE)."""
"""Model-facing ``fetch_local`` tool (HTTP GET/POST/PUT/DELETE from this host)."""
from __future__ import annotations
@@ -62,7 +62,7 @@ def format_fetch_result(
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO | ToolTag.TRUSTABLE | ToolTag.READ_ONLY)
async def fetch(
async def fetch_local(
url: str,
*,
method: str = "GET",
@@ -80,6 +80,10 @@ async def fetch(
"""HTTP request (GET/POST/PUT/DELETE); return status, metadata, and truncated body.
Use for public docs/APIs or (after human policy allow) local/LAN servers.
``offset`` skips that many leading characters of the body (0-based), for
paging through a body larger than ``max_chars``; a body truncated by
``max_chars`` keeps its remainder in memory instead, resumable via its
``truncate_token`` without another request.
``user_agent`` sets User-Agent when provided and takes precedence over a
User-Agent entry in ``headers``. A headers-only User-Agent is kept as-is.
When both omit UA, a small default is used.
@@ -93,7 +97,7 @@ async def fetch(
try:
verb = normalize_method(method)
if is_read_only_context() and verb != "GET":
return "error: fetch method must be GET in a read-only context (POST/PUT/DELETE mutate)"
return "error: fetch_local method must be GET in a read-only context (POST/PUT/DELETE mutate)"
parsed = parse_fetch_url(url)
hdrs = normalize_request_headers(headers, user_agent=user_agent)
body_bytes: bytes | None = None
@@ -115,7 +119,7 @@ async def fetch(
except FetchPolicyError as exc:
return f"error: {exc}"
except Exception as exc: # noqa: BLE001 — tool surface returns error text
return f"error: fetch failed: {exc}"
return f"error: fetch_local failed: {exc}"
return format_fetch_result(
status=result.status,
+88
View File
@@ -0,0 +1,88 @@
"""Model-facing ``fetch``: read a public page through DeepSeek's Fetch API.
The page is rendered to Markdown server-side, so it is the friendly counterpart
of ``fetch_local``: use it for public articles/docs, and keep ``fetch_local``
for localhost/LAN targets, non-GET requests, custom headers, and raw status or
redirect inspection. The request leaves from DeepSeek's network, not this host,
so local SSRF checks and private-host grants do not apply here.
"""
from __future__ import annotations
import inspect
from typing import TYPE_CHECKING
from plyngent.agent.tools import ToolDefinition, ToolTag, schema_from_callable
from plyngent.tools.net.policy import DEFAULT_MAX_CHARS
from plyngent.tools.truncate_token import truncate_to_memory
if TYPE_CHECKING:
from plyngent.lmproto.deepseek.webtools import DeepseekWebToolsClient
FETCH_TOOL_NAME = "fetch"
def format_page_result(
*,
title: str,
url: str,
content: str,
max_chars: int,
offset: int = 0,
) -> str:
"""Render a fetched page the way the other structured tool results read."""
start = max(0, offset)
body, token = truncate_to_memory(content[start:], max_chars)
parts = [
f"title={' '.join(title.split()) or '(untitled)'}",
f"url={url}",
f"chars={len(content)}",
f"truncated={'true' if token is not None else 'false'}",
"--- content ---",
body,
]
return "\n".join(parts)
def build_fetch_tool(
client: DeepseekWebToolsClient,
*,
default_max_chars: int = DEFAULT_MAX_CHARS,
) -> ToolDefinition:
"""A ``fetch`` definition backed by DeepSeek's Fetch API."""
async def fetch(url: str, offset: int = 0, max_chars: int = default_max_chars) -> str:
"""Read a public web page as Markdown through DeepSeek's Fetch API.
Use for public articles and docs when clean Markdown is enough. The page
is fetched from DeepSeek's network, so localhost/LAN targets are
unreachable here — use ``fetch_local`` for those, for non-GET methods, or
when you need the raw HTTP status/headers. A successful fetch is billed
per request (failures are not).
``offset`` (0-based) skips leading characters of the content. A body cut
by ``max_chars`` keeps its remainder in memory, resumable via its
``truncate_token`` without another request (and without another charge).
"""
try:
page = await client.fetch(url)
except Exception as exc: # noqa: BLE001 — tool surface returns error text
return f"error: fetch failed: {exc}"
return format_page_result(
title=page.title,
url=url,
content=page.content,
max_chars=max_chars,
offset=offset,
)
return ToolDefinition(
name=FETCH_TOOL_NAME,
description=inspect.getdoc(fetch) or "",
parameters=schema_from_callable(fetch),
handler=fetch,
tags=ToolTag.LOCAL | ToolTag.READ_ONLY,
)
__all__ = ["FETCH_TOOL_NAME", "build_fetch_tool", "format_page_result"]
+5 -5
View File
@@ -77,7 +77,7 @@ async def ensure_host_allowed(
try:
inst = instance if instance is not None else require_bound_instance()
except WorkspaceError as exc:
msg = f"instance state is not bound for fetch policy: {exc}"
msg = f"instance state is not bound for fetch_local policy: {exc}"
raise FetchPolicyError(msg) from exc
parsed = parse_fetch_url(url)
@@ -85,7 +85,7 @@ async def ensure_host_allowed(
if assessment.classification is HostClass.PUBLIC:
return
if assessment.classification is HostClass.FORBIDDEN:
msg = f"fetch blocked forbidden host {parsed.host!r} (addresses: {', '.join(assessment.addresses)})"
msg = f"fetch_local blocked forbidden host {parsed.host!r} (addresses: {', '.join(assessment.addresses)})"
raise FetchPolicyError(msg)
# PRIVATE
@@ -100,18 +100,18 @@ async def ensure_host_allowed(
hook = get_fetch_policy_confirm_hook(inst)
if hook is None:
msg = (
f"fetch blocked private/loopback host {parsed.host}:{parsed.port} "
f"fetch_local blocked private/loopback host {parsed.host}:{parsed.port} "
f"({assessment.reason}; no policy confirm hook / non-interactive deny)"
)
raise FetchPolicyError(msg)
try:
allowed = bool(hook(parsed.host, parsed.port, parsed.url, timeout))
except Exception as exc:
msg = f"fetch blocked private/loopback host {parsed.host}:{parsed.port} (confirm failed: {exc})"
msg = f"fetch_local blocked private/loopback host {parsed.host}:{parsed.port} (confirm failed: {exc})"
raise FetchPolicyError(msg) from exc
if not allowed:
msg = (
f"fetch blocked private/loopback host {parsed.host}:{parsed.port} "
f"fetch_local blocked private/loopback host {parsed.host}:{parsed.port} "
f"(user declined or timed out after {timeout:g}s; not skipped by YOLO)"
)
raise FetchPolicyError(msg)
+1 -1
View File
@@ -1,4 +1,4 @@
"""URL / host policy for the fetch tool (SSRF, private grants, cleartext)."""
"""URL / host policy for the fetch_local tool (SSRF, private grants, cleartext)."""
from __future__ import annotations
+19 -19
View File
@@ -1,13 +1,16 @@
"""``web_search``: the model's way to search when its surface cannot host one.
DeepSeek only searches on its Anthropic-compatible surface, so a DeepSeek
provider that talks the Responses or chat-completions surface gets this local
tool instead of a hosted one (see ``config.routing.local_web_search``): the
model calls it like any other tool and the handler asks the configured search
sources behind the scenes — ``provider.search_sources``, in fallback order, so
the next source is tried when one fails or finds nothing. A hit is a title, a
URL and (when the source has one) a snippet: read a page with ``fetch`` before
relying on its content.
DeepSeek hosts a search only on its Anthropic-compatible surface, so a DeepSeek
provider that talks the Responses or chat-completions surface — or one that
turns hosted tools off — gets this local tool instead (see
``config.routing.local_web_search``): the model calls it like any other tool and
the handler asks the configured search sources behind the scenes —
``provider.search_sources``, in fallback order, so the next source is tried when
one fails or finds nothing. DeepSeek's own source is the Search API
(``POST /v1/tools/search``), which answers in one request instead of a model
round. A hit is a title, a URL and (when the source has one) a snippet: read a
page with ``fetch`` (or ``fetch_local`` for local/LAN targets) before relying on
its content.
"""
from __future__ import annotations
@@ -20,10 +23,8 @@ from plyngent.agent.tools import ToolDefinition, ToolTag, schema_from_callable
if TYPE_CHECKING:
from collections.abc import Sequence
from plyngent.lmproto.deepseek.anthropic.search import (
DeepseekSearchClient,
SearchHit,
)
from plyngent.lmproto.deepseek.webtools import DeepseekWebToolsClient
from plyngent.lmproto.search import SearchHit
WEB_SEARCH_TOOL_NAME = "web_search"
DEFAULT_MAX_HITS = 8
@@ -36,17 +37,15 @@ class SearchBackend(Protocol):
class DeepseekSearchBackend:
"""DeepSeek's own index: one server-side search turn per query."""
"""DeepSeek's own index: one Search API request per query."""
_client: DeepseekSearchClient
_model: str
_client: DeepseekWebToolsClient
def __init__(self, client: DeepseekSearchClient, *, model: str) -> None:
def __init__(self, client: DeepseekWebToolsClient) -> None:
self._client = client
self._model = model
async def search(self, query: str) -> Sequence[SearchHit]:
return await self._client.search(query, model=self._model)
return await self._client.search(query)
async def search_in_order(
@@ -84,7 +83,8 @@ def build_web_search_tool(
"""Search the web and return the pages that matched.
One entry per hit (``title — url``, plus the snippet when the source has
one): read a page with ``fetch`` before relying on its content.
one): read a page with ``fetch`` (or ``fetch_local`` for local targets)
before relying on its content.
"""
found, failures = await search_in_order(backends, query)
if not found:
-2
View File
@@ -465,7 +465,6 @@ async def skill_create(
_write_file(path, content)
except OSError as exc:
return f"error: failed to create skill {token!r}: {exc}"
store.reload()
return f"created skill {token!r} at {target}{_skill_notes(target / SKILL_FILE)}"
@@ -571,7 +570,6 @@ async def skill_edit(
_write_file(target, updated)
except OSError as exc:
return f"error: failed to write {relative} of skill {skill.name!r}: {exc}"
store.reload()
notes = _skill_notes(target) if target.name == SKILL_FILE else ""
return f"updated skill {skill.name!r} ({detail}){notes}"
+27 -13
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, cast
from plyngent.agent import ToolTag, tool
from plyngent.tools.chat.shape import number_arg
if TYPE_CHECKING:
from collections.abc import Callable
@@ -52,6 +53,10 @@ async def _with_todo_stack(mutator: Callable[[TodoStack], str]) -> str:
async def todo_list() -> str:
"""Show the LIFO stack of **task groups** (TOP group = current breakdown level).
Items are numbered in the order they print (1-based, TOP group first). Those
numbers are listing positions, not stored ids, so they shift whenever the
stack changes — take them from the latest listing.
Non-empty stack with open (pending/in_progress) items = unfinished work.
All-terminal but non-empty = hygiene only (pop/clear). Push creates one
group of siblings; pop removes the whole top group.
@@ -82,8 +87,8 @@ async def todo_push(titles: list[str], notes: str = "") -> str:
group = stack.push_group(parsed, notes=notes)
except ValueError as exc:
return f"error: {exc}"
ids = ", ".join(i.id for i in group.items)
return f"pushed group (depth={stack.depth}) items=[{ids}]\n{stack.render()}"
pushed = ", ".join(i.title for i in group.items)
return f"pushed group (depth={stack.depth}) items=[{pushed}]\n{stack.render()}"
return await _with_todo_stack(_run)
@@ -100,27 +105,36 @@ async def todo_pop() -> str:
group = stack.pop()
if group is None:
return "todo stack empty"
titles = ", ".join(f"{i.id}:{i.title}" for i in group.items) or "(empty)"
popped_titles = ", ".join(i.title for i in group.items) or "(empty)"
top = stack.top_group
top_s = "(empty)" if top is None else ", ".join(i.id for i in top.items)
return f"popped TOP group ({titles}); new top group=[{top_s}]\n{stack.render()}"
top_titles = "(empty)" if top is None else ", ".join(i.title for i in top.items)
return f"popped TOP group ({popped_titles}); new top group=[{top_titles}]\n{stack.render()}"
return await _with_todo_stack(_run)
@tool(name="todo_update", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
async def todo_update(
item_id: str,
number: int,
status: str = "",
title: str = "",
notes: str = "",
) -> str:
"""Update a task by id inside any group.
"""Update a task by the number the listing shows (1-based, TOP group first).
Open items are unfinished work: mark done/cancelled when the work is truly
finished (not just deferred). Pop the TOP group when that breakdown level
is complete so the stack does not linger as false open work.
Numbers come from the latest listing and shift when the stack changes, so
re-list rather than reusing an older number. Open items are unfinished work:
mark done/cancelled when the work is truly finished (not just deferred). Pop
the TOP group when that breakdown level is complete so the stack does not
linger as false open work.
"""
item_number, number_error = number_arg("todo_update", "number", number, example="2")
if number_error is not None:
return f"error: {number_error}"
assert item_number is not None # number_arg returns a value whenever it accepts
if not item_number.is_integer():
return "error: `todo_update` expects `number` as a whole item number; pass e.g. 2"
target = int(item_number)
def _run(stack: TodoStack) -> str:
status_arg: TodoStatus | None = None
@@ -131,14 +145,14 @@ async def todo_update(
status_arg = cast("TodoStatus", token)
try:
item = stack.update(
item_id.strip(),
target,
title=title if title.strip() else None,
status=status_arg,
notes=notes if notes != "" else None,
)
except (KeyError, ValueError) as exc:
except ValueError as exc:
return f"error: {exc}"
return f"updated {item.id} → {item.status}: {item.title}\n{stack.render()}"
return f"updated {target} → {item.status}: {item.title}\n{stack.render()}"
return await _with_todo_stack(_run)
+5 -5
View File
@@ -2,8 +2,8 @@
Every truncation site (the agent loop's generic tool-result cap,
``run_argv`` / ``run_argv_batch`` per-stream caps, ``read_file``,
``fetch``, and request-time compact shrinks) appends the same compact marker
when output is cut short::
``fetch`` / ``fetch_local``, and request-time compact shrinks) appends the same
compact marker when output is cut short::
[Truncated (32000 chars max.; 500 omitted). truncate_token=...]
@@ -14,9 +14,9 @@ source is exhausted (then no marker is emitted).
A ``file`` remainder is re-read from disk by ``location`` + ``offset``, so it
survives for as long as the file does. Everything else — arbitrary tool output
(run_argv stdout, todo renders, …) and ``fetch`` bodies — has no source worth
re-running (a re-fetch costs a round trip and re-hits the remote), so its
remainder lives in a short-lived in-memory store (``kind="memory"``) that is
(run_argv stdout, todo renders, …) and ``fetch`` / ``fetch_local`` bodies — have
no source worth re-running (a re-fetch costs a round trip and re-hits the remote),
so its remainder lives in a short-lived in-memory store (``kind="memory"``) that is
forgotten when the agent process exits; the model must re-run the tool after
that.
"""
+1 -1
View File
@@ -393,7 +393,7 @@ def resolve_path(path: str | Path, *, required: AccessMode = AccessMode.WRITE) -
if not _under_any_root(resolved, instance, policy) and not _path_grant_covers(resolved, required):
msg = (
f"path escapes workspace root ({root}): {path}; "
"call request_directory_access to request access to a directory outside the workspace"
f"call request_directory_access with mode={required.name.lower()!r} to request access outside the workspace"
)
raise WorkspaceError(msg)
# Normalize separators so denylist entries like ``/secrets/`` match on Windows.
@@ -130,6 +130,15 @@ async def test_summarize_messages() -> None:
assert "world" in user_msg.content
async def test_summarize_default_prompt_says_tools_are_unavailable() -> None:
client = SummaryClient()
_ = await summarize_messages(client, [UserChatMessage(content="hi")], model="m")
assert client.last is not None
sys_msg = client.last.messages[0]
assert isinstance(sys_msg, SystemChatMessage)
assert "tool calls are unavailable" in sys_msg.content.lower()
async def test_summarize_custom_prompts() -> None:
client = SummaryClient()
_ = await summarize_messages(
+103 -6
View File
@@ -14,10 +14,12 @@ from plyngent.agent import (
ToolCallEvent,
ToolRegistry,
ToolResultEvent,
TruncatedEvent,
UsageEvent,
run_chat_loop,
tool,
)
from plyngent.agent.notices import TRUNCATED_NOTICE_KIND, notice_of_kind
from plyngent.config.models import DatabaseConfig
from plyngent.lmproto.openai_compatible.model import (
AnyChatMessage,
@@ -987,20 +989,115 @@ async def test_empty_completion_raises() -> None:
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
async def test_length_finish_raises() -> None:
async def test_length_finish_keeps_the_partial_answer_and_continues() -> None:
client = ScriptedClient(
[
_response(
AssistantChatMessage(content="partial"),
finish_reason="length",
)
_response(AssistantChatMessage(content="partial"), finish_reason="length"),
_response(AssistantChatMessage(content="rest")),
]
)
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
with pytest.raises(RuntimeError, match="truncated"):
events = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
truncated = [e for e in events if isinstance(e, TruncatedEvent)]
assert len(truncated) == 1
assert truncated[0].kept_chars == len("partial")
assert truncated[0].continued
# The partial answer stays in history under a truncation notice, and the
# continuation round is sent that history — the model continues its answer
# rather than writing it again from the start.
assert isinstance(messages[1], AssistantChatMessage)
assert messages[1].content == "partial"
assert notice_of_kind(messages[2], TRUNCATED_NOTICE_KIND)
assert isinstance(messages[3], AssistantChatMessage)
assert messages[3].content == "rest"
assert any(notice_of_kind(m, TRUNCATED_NOTICE_KIND) for m in client.calls[1].messages)
async def test_length_finish_stops_once_the_continuation_budget_is_spent() -> None:
client = ScriptedClient([_response(AssistantChatMessage(content="partial"), finish_reason="length")] * 2)
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
events = [
e
async for e in run_chat_loop(
client,
messages,
model="m",
stream=False,
max_truncation_continues=1,
)
]
assert [e.continued for e in events if isinstance(e, TruncatedEvent)] == [True, False]
assert len(client.calls) == 2
# Nothing raised: the turn ends with the partial answer kept, so `/retry`
# continues it instead of regenerating.
assert isinstance(messages[-2], AssistantChatMessage)
assert messages[-2].content == "partial"
assert notice_of_kind(messages[-1], TRUNCATED_NOTICE_KIND)
async def test_length_finish_drops_the_rounds_tool_calls() -> None:
ran: list[str] = []
@tool(name="boom", register=False)
async def _boom(argument: str) -> str:
ran.append(argument)
return "ran"
call = AssistantFunctionToolCall(
id="c1",
function=AssistantFunctionTool(name="boom", arguments='{"argument": "x"'),
)
client = ScriptedClient(
[
_response(
AssistantChatMessage(content="partial", tool_calls=[call]),
finish_reason="length",
),
_response(AssistantChatMessage(content="done")),
]
)
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
events = [e async for e in run_chat_loop(client, messages, model="m", tools=ToolRegistry([_boom]), stream=False)]
# A call cut off mid-write is not replayed: the model re-issues it after
# reading the notice.
assert ran == []
assert not [e for e in events if isinstance(e, (ToolCallEvent, ToolResultEvent))]
kept = messages[1]
assert isinstance(kept, AssistantChatMessage)
assert kept.content == "partial"
assert kept.tool_calls is UNSET
async def test_length_finish_without_content_still_raises() -> None:
client = ScriptedClient([_response(AssistantChatMessage(content=None), finish_reason="length")])
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
with pytest.raises(RuntimeError, match="output limit reached with nothing written"):
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
async def test_truncated_turn_is_retryable() -> None:
store = await MemoryStore.open(DatabaseConfig())
session = await store.create_session(name="t")
client = ScriptedClient(
[
_response(AssistantChatMessage(content="partial"), finish_reason="length"),
_response(AssistantChatMessage(content="rest")),
]
)
agent = ChatAgent(client, model="m", memory=store, session_id=session.sid, max_truncation_continues=0, stream=False)
_ = [e async for e in agent.run("hi")]
# Kept, not rolled back: the turn is unfinished, so retry continues it.
assert agent.pending_retry_text == "hi"
_ = [e async for e in agent.retry()]
assert any(notice_of_kind(m, TRUNCATED_NOTICE_KIND) for m in client.calls[1].messages)
assert agent.pending_retry_text is None
await store.close()
async def test_content_filter_finish_raises() -> None:
client = ScriptedClient(
[
+130 -3
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import msgspec
from msgspec import UNSET
from plyngent.agent.messages_bridge import (
@@ -14,6 +15,7 @@ from plyngent.agent.messages_bridge import (
chat_messages_to_anthropic,
chat_param_to_anthropic_param,
provider_tools_to_anthropic_tools,
thinking_config_refused,
tool_items_to_anthropic_tools,
)
from plyngent.lmproto.anthropic.model import (
@@ -156,15 +158,20 @@ 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."""
def test_anthropic_response_to_assistant_keeps_thinking_and_search_blocks() -> None:
"""A server-side search is not a registry call, but it is the round's evidence."""
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"}),
AnthropicServerToolUseContent(
id="call_00_1",
name="web_search",
input={"query": "DeepSeek-V4"},
caller={"type": "direct"},
),
AnthropicWebSearchToolResultContent(
tool_use_id="call_00_1",
content=[{"type": "web_search_result", "url": "https://api-docs.deepseek.com"}],
@@ -176,6 +183,65 @@ def test_anthropic_response_to_assistant_ignores_server_tool_blocks() -> None:
assistant = anthropic_response_to_assistant(response)
assert assistant.content == "DeepSeek-V4 is out."
assert assistant.tool_calls is UNSET
# The chain of thought is the round's reasoning text; the signature it must
# go back with only exists on the block.
assert assistant.reasoning_content == "Let me search."
assert assistant.provider_blocks == [
{"type": "thinking", "thinking": "Let me search.", "signature": "sig-1"},
{
"type": "server_tool_use",
"id": "call_00_1",
"name": "web_search",
"input": {"query": "DeepSeek-V4"},
"caller": {"type": "direct"},
},
{
"type": "web_search_tool_result",
"tool_use_id": "call_00_1",
"content": [{"type": "web_search_result", "url": "https://api-docs.deepseek.com"}],
},
]
def test_chat_param_to_anthropic_param_hands_provider_blocks_back() -> None:
"""The next request carries the round's thinking and search blocks first."""
assistant = AssistantChatMessage(
content="DeepSeek-V4 is out.",
reasoning_content="Let me search.",
provider_blocks=[
{"type": "thinking", "thinking": "Let me search.", "signature": "sig-1"},
{
"type": "web_search_tool_result",
"tool_use_id": "call_00_1",
"content": [{"type": "web_search_result", "encrypted_content": "sd918tZXSvVp"}],
},
],
tool_calls=[
AssistantFunctionToolCall(
id="tu_1",
function=AssistantFunctionTool(name="read_file", arguments='{"path": "a.py"}'),
)
],
)
param = ChatCompletionsParam(model="deepseek-flash", messages=[assistant])
built = chat_param_to_anthropic_param(param)
# The wire shape: DeepSeek answers a trimmed ``encrypted_content`` with 422.
data = msgspec.json.decode(msgspec.json.encode(built))
assert data["messages"] == [
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Let me search.", "signature": "sig-1"},
{
"type": "web_search_tool_result",
"tool_use_id": "call_00_1",
"content": [{"type": "web_search_result", "encrypted_content": "sd918tZXSvVp"}],
},
{"type": "text", "text": "DeepSeek-V4 is out."},
{"type": "tool_use", "id": "tu_1", "name": "read_file", "input": {"path": "a.py"}},
],
}
]
def test_provider_tools_to_anthropic_tools_maps_the_search_intent() -> None:
@@ -322,3 +388,64 @@ def test_chat_param_to_anthropic_param_thinking_disabled() -> None:
assert body.thinking is not UNSET
assert body.thinking.type == "disabled"
assert body.thinking.budget_tokens is UNSET
def test_chat_param_to_anthropic_param_adaptive_thinking_from_effort() -> None:
body = chat_param_to_anthropic_param(_effort_param("high"), adaptive_thinking=True)
assert body.thinking is not UNSET
assert body.thinking.type == "adaptive"
assert body.thinking.budget_tokens is UNSET
assert body.output_config is not UNSET
assert body.output_config.effort == "high"
# Adaptive thinking spends no fixed budget, so max_tokens stays as asked.
assert body.max_tokens == 8192
def test_chat_param_to_anthropic_param_adaptive_effort_levels() -> None:
def effort_for(level: ReasoningEffort) -> str:
body = chat_param_to_anthropic_param(_effort_param(level), adaptive_thinking=True)
assert body.output_config is not UNSET
return str(body.output_config.effort)
assert effort_for("minimal") == "low" # no such API level; the lowest one
assert effort_for("xhigh") == "xhigh"
def test_chat_param_to_anthropic_param_adaptive_ignores_a_configured_budget() -> None:
body = chat_param_to_anthropic_param(_effort_param("medium"), thinking_budget_tokens=20000, adaptive_thinking=True)
assert body.thinking is not UNSET
assert body.thinking.type == "adaptive"
assert body.thinking.budget_tokens is UNSET
assert body.max_tokens == 8192
def test_chat_param_to_anthropic_param_adaptive_disabled_carries_no_effort() -> None:
body = chat_param_to_anthropic_param(_effort_param("none"), adaptive_thinking=True)
assert body.thinking is not UNSET
assert body.thinking.type == "disabled"
assert body.output_config is UNSET
def test_chat_param_to_anthropic_param_adaptive_without_effort_sends_nothing() -> None:
body = chat_param_to_anthropic_param(_effort_param(), adaptive_thinking=True)
assert body.thinking is UNSET
assert body.output_config is UNSET
def test_thinking_config_refused_matches_the_api_and_gateway_wording() -> None:
documented_enabled = (
'messages HTTP 400: {"error":{"message":"\\"thinking.type.enabled\\" is not supported for this '
'model. Use \\"thinking.type.adaptive\\" and \\"output_config.effort\\" to control thinking behavior."}}'
)
documented_disabled = (
'messages HTTP 400: {"error":{"message":"\\"thinking.type.disabled\\" is not supported for this '
'model. Use \\"thinking.type.adaptive\\" and \\"output_config.effort\\" to control thinking behavior."}}'
)
gateway = (
'messages HTTP 400: {"error":{"message":"claude-opus-5-5 requires adaptive thinking; omit thinking '
'or use thinking.type=adaptive and output_config.effort"}}'
)
assert thinking_config_refused(documented_enabled)
assert thinking_config_refused(documented_disabled)
assert thinking_config_refused(gateway)
assert not thinking_config_refused('messages HTTP 400: {"error":{"message":"max_tokens: required"}}')
+213 -20
View File
@@ -4,10 +4,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, cast, overload
import pytest
from msgspec import UNSET
from plyngent.agent import (
AssistantMessageEvent,
ReasoningDeltaEvent,
TextDeltaEvent,
ToolCallEvent,
ToolRegistry,
@@ -44,6 +46,21 @@ if TYPE_CHECKING:
from collections.abc import AsyncIterator, Sequence
# What the API answers a request whose thinking block it does not take (a gateway
# paraphrase of the documented wording, which names adaptive thinking either way).
_THINKING_REFUSED_400: dict[str, str] = {
"enabled": (
'messages HTTP 400: {"error":{"type":"error","message":"claude-opus-5-5 requires adaptive '
'thinking; omit thinking or use thinking.type=adaptive and output_config.effort"}}'
),
"disabled": (
'messages HTTP 400: {"error":{"type":"error","message":"\\"thinking.type.disabled\\" is not '
'supported for this model. Use \\"thinking.type.adaptive\\" and \\"output_config.effort\\" to '
'control thinking behavior."}}'
),
}
def _message_response(
*,
text: str = "hello",
@@ -72,21 +89,43 @@ class ScriptedMessagesClient:
kind: str = "messages"
calls: list[AnthropicMessagesParam]
# Mirrors AnthropicClient: explicit thinking budget (0 = derive from effort).
# Mirrors AnthropicClient: explicit thinking budget (0 = derive from effort),
# and the thinking types the dispatch found the API refusing.
thinking_budget_tokens: int
refused_thinking_types: dict[str, set[str]]
_non_stream: list[AnthropicMessageResponse]
_stream_events: list[list[AnthropicStreamEvent]]
_refusals: frozenset[str]
_error: str | None
def __init__(
self,
*,
non_stream: Sequence[AnthropicMessageResponse] | None = None,
stream_events: Sequence[Sequence[AnthropicStreamEvent]] | None = None,
refusals: Sequence[str] = (),
error: str | None = None,
) -> None:
self.calls = []
self.thinking_budget_tokens = 0
self.refused_thinking_types = {}
self._non_stream = list(non_stream or ())
self._stream_events = [list(events) for events in (stream_events or ())]
self._refusals = frozenset(refusals)
self._error = error
def refuses_thinking_type(self, model: str, thinking_type: str) -> bool:
return thinking_type in self.refused_thinking_types.get(model, ())
def note_refused_thinking_type(self, model: str, thinking_type: str) -> None:
self.refused_thinking_types.setdefault(model, set()).add(thinking_type)
def _reject(self, param: AnthropicMessagesParam) -> None:
"""Raise what the API raises for a request it refuses to run."""
if self._error is not None:
raise RuntimeError(self._error)
if param.thinking is not UNSET and param.thinking.type in self._refusals:
raise RuntimeError(_THINKING_REFUSED_400[param.thinking.type])
@overload
async def messages(
@@ -106,13 +145,16 @@ class ScriptedMessagesClient:
if not self._stream_events:
msg = "no more scripted stream events"
raise RuntimeError(msg)
events = self._stream_events.pop(0)
async def _gen() -> AsyncIterator[AnthropicStreamEvent]:
for event in events:
# The real client reads the error body on the first iteration,
# so a refused request consumes no scripted events.
self._reject(param)
for event in self._stream_events.pop(0):
yield event
return _gen()
self._reject(param)
if not self._non_stream:
msg = "no more scripted messages"
raise RuntimeError(msg)
@@ -396,9 +438,9 @@ async def test_dispatch_stream_tool_calls_without_stop_yields_no_finish() -> Non
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]:
def _search_stream_events(*, text: bool = True) -> list[AnthropicStreamEvent]:
"""The stream a DeepSeek search turn sends: thinking, search, results, text."""
return [
events: list[AnthropicStreamEvent] = [
AnthropicMessageStart(
message=AnthropicMessageResponse(
id="msg_s",
@@ -407,11 +449,16 @@ def _search_stream_events() -> list[AnthropicStreamEvent]:
usage=AnthropicUsage(input_tokens=164, output_tokens=0),
)
),
AnthropicContentBlockStart(index=0, content_block=AnthropicRawContentBlock(type="thinking", text="")),
AnthropicContentBlockStart(
index=0,
content_block=AnthropicRawContentBlock(type="thinking", thinking=""),
),
AnthropicContentBlockDelta(index=0, delta=AnthropicRawContentBlock(type="thinking_delta", thinking="Search ")),
AnthropicContentBlockDelta(
index=0,
delta=AnthropicRawContentBlock(type="thinking_delta", text="Search."),
delta=AnthropicRawContentBlock(type="thinking_delta", thinking="the docs."),
),
AnthropicContentBlockDelta(index=0, delta=AnthropicRawContentBlock(type="signature_delta", signature="sig-1")),
AnthropicContentBlockStop(index=0),
AnthropicContentBlockStart(
index=1,
@@ -430,23 +477,39 @@ def _search_stream_events() -> list[AnthropicStreamEvent]:
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"}]},
tool_use_id="call_00_1",
content=[
{
"type": "web_search_result",
"url": "https://api-docs.deepseek.com",
"encrypted_content": "sd918tZXSvVp",
}
],
),
),
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(),
]
if text:
events.extend(
[
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),
]
)
events.extend(
[
AnthropicMessageDelta(
delta={"stop_reason": "end_turn"},
usage=AnthropicUsage(input_tokens=0, output_tokens=12),
),
AnthropicMessageStop(),
]
)
return events
async def test_search_turn_streams_its_text_without_a_local_tool_call() -> None:
@@ -476,10 +539,58 @@ async def test_search_turn_streams_its_text_without_a_local_tool_call() -> None:
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)
# The chain of thought streams as reasoning, fragment by fragment.
assert [event.content for event in events if isinstance(event, ReasoningDeltaEvent)] == ["Search ", "the docs."]
# ...and the round keeps the search blocks, rebuilt, for the next request.
assistant = next(event.message for event in events if isinstance(event, AssistantMessageEvent))
assert assistant.reasoning_content == "Search the docs."
assert assistant.provider_blocks == [
{"type": "thinking", "thinking": "Search the docs.", "signature": "sig-1"},
{
"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",
"url": "https://api-docs.deepseek.com",
"encrypted_content": "sd918tZXSvVp",
}
],
},
]
# One round: the search answered inside the response, so nothing was sent back.
assert len(client.calls) == 1
async def test_search_only_round_is_not_an_empty_completion() -> None:
"""A search turn with no text yet still produced payload.
A budget-capped or ``max_uses_exceeded`` search can end the turn on its
blocks alone; calling that an empty completion failed a round the model had
actually used.
"""
client = ScriptedMessagesClient(stream_events=[_search_stream_events(text=False)])
events = [
event
async for event in run_chat_loop(
cast("Any", client),
[UserChatMessage(content="what is DeepSeek-V4?")],
model="deepseek-flash",
stream=True,
)
]
assistant = next(event.message for event in events if isinstance(event, AssistantMessageEvent))
assert assistant.content is None
assert assistant.provider_blocks is not UNSET
assert len(assistant.provider_blocks) == 3
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")])
@@ -532,3 +643,85 @@ async def test_search_turn_reports_the_prompt_the_delta_counts() -> None:
assert isinstance(usage, dict)
assert usage["prompt_tokens"] == 12468
assert usage["completion_tokens"] == 198
def _thinking_param(model: str = "claude-opus-5-5", effort: str = "high") -> ChatCompletionsParam:
return ChatCompletionsParam(
model=model,
messages=[UserChatMessage(content="hi")],
reasoning_effort=cast("Any", effort),
)
async def test_dispatch_messages_retries_with_adaptive_thinking() -> None:
client = ScriptedMessagesClient(non_stream=[_message_response(text="done")], refusals=("enabled",))
result = await dispatch_messages(cast("Any", client), _thinking_param(), stream=False)
assert result.choices[0].message.content == "done"
assert len(client.calls) == 2
refused, retried = client.calls
assert refused.thinking is not UNSET
assert refused.thinking.type == "enabled"
assert retried.thinking is not UNSET
assert retried.thinking.type == "adaptive"
assert retried.output_config is not UNSET
assert retried.output_config.effort == "high"
async def test_dispatch_messages_remembers_the_refusing_model() -> None:
client = ScriptedMessagesClient(
non_stream=[_message_response(text="one"), _message_response(text="two")],
refusals=("enabled",),
)
param = _thinking_param()
_ = await dispatch_messages(cast("Any", client), param, stream=False)
_ = await dispatch_messages(cast("Any", client), param, stream=False)
# Only the model's first request pays the refused attempt.
assert [call.thinking.type for call in client.calls] == ["enabled", "adaptive", "adaptive"]
async def test_dispatch_messages_drops_a_refused_disabled_block() -> None:
"""A model whose thinking is always on answers ``disabled`` with a 400."""
client = ScriptedMessagesClient(
non_stream=[_message_response(text="one"), _message_response(text="two")],
refusals=("enabled", "disabled"),
)
param = _thinking_param(effort="none")
result = await dispatch_messages(cast("Any", client), param, stream=False)
assert result.choices[0].message.content == "one"
assert len(client.calls) == 2
assert client.calls[0].thinking.type == "disabled"
# No block at all: there is nothing left to ask such a model for.
assert client.calls[1].thinking is UNSET
_ = await dispatch_messages(cast("Any", client), param, stream=False)
assert client.calls[2].thinking is UNSET
async def test_dispatch_messages_stream_retries_with_adaptive_thinking() -> None:
events: list[AnthropicStreamEvent] = [
AnthropicContentBlockStart(index=0, content_block=AnthropicRawContentBlock(type="text", text="")),
AnthropicContentBlockDelta(index=0, delta=AnthropicRawContentBlock(type="text_delta", text="streamed")),
AnthropicMessageDelta(delta={"stop_reason": "end_turn"}, usage=AnthropicUsage(output_tokens=3)),
AnthropicMessageStop(),
]
client = ScriptedMessagesClient(stream_events=[events], refusals=("enabled",))
stream = await dispatch_messages(cast("Any", client), _thinking_param(), stream=True)
chunks = [chunk async for chunk in cast("Any", stream)]
texts = [c.choices[0].delta.content for c in chunks if c.choices and isinstance(c.choices[0].delta.content, str)]
assert texts == ["streamed"]
assert len(client.calls) == 2
assert client.calls[1].thinking.type == "adaptive"
async def test_dispatch_messages_does_not_retry_other_failures() -> None:
client = ScriptedMessagesClient(error='messages HTTP 400: {"error":{"message":"max_tokens: required"}}')
with pytest.raises(RuntimeError, match="max_tokens"):
_ = await dispatch_messages(cast("Any", client), _thinking_param(), stream=False)
assert len(client.calls) == 1
async def test_dispatch_messages_does_not_retry_a_request_without_thinking() -> None:
client = ScriptedMessagesClient(error=_THINKING_REFUSED_400["enabled"])
param = ChatCompletionsParam(model="claude-opus-5-5", messages=[UserChatMessage(content="hi")])
with pytest.raises(RuntimeError, match="adaptive thinking"):
_ = await dispatch_messages(cast("Any", client), param, stream=False)
assert len(client.calls) == 1
+58 -1
View File
@@ -2,7 +2,16 @@ from __future__ import annotations
from datetime import UTC, datetime, timedelta, timezone
from plyngent.agent import ChatAgent, Notice, aside_notice, format_moment, resume_notice
from plyngent.agent import (
ChatAgent,
Notice,
aside_notice,
format_moment,
has_notice,
resume_notice,
skill_tools_notice,
truncated_turn_notice,
)
from plyngent.agent.chat import incomplete_turn_user_text
from plyngent.config.models import DatabaseConfig
from plyngent.lmproto.openai_compatible.model import (
@@ -57,6 +66,31 @@ def test_aside_notice_matches_tool_scope() -> None:
assert aside_notice(tools_mode="weird").body == read.body
def test_skill_tools_notice_points_at_the_tools_without_listing() -> None:
notice = skill_tools_notice()
assert notice.kind == "skills"
# Instructions only — what exists is asked for, never injected.
assert "not listed here" in notice.body
assert "`skill_list`" in notice.body
assert "`skill_search`" in notice.body
assert "`skill_read NAME [file]`" in notice.body
assert "data, not code" in notice.body
assert "skill_list" in notice.summary
def test_has_notice_finds_a_persisted_row_by_kind() -> None:
messages = [
SystemChatMessage(content="sys"),
skill_tools_notice().to_message(),
resume_notice(last_active=None).to_message(),
]
assert has_notice(messages, "skills") is True
assert has_notice(messages, "resume") is True
assert has_notice(messages, "aside") is False
# A plain developer row (a directive checkpoint) is not a notice.
assert has_notice([DeveloperChatMessage(content="checkpoint")], "skills") is False
async def test_push_notice_appends_and_persists() -> None:
store = await MemoryStore.open(DatabaseConfig())
try:
@@ -99,3 +133,26 @@ def test_trailing_developer_message_keeps_incomplete_turn_detectable() -> None:
# A finished turn (trailing assistant) stays non-retryable past a notice.
done = [UserChatMessage(content="do it"), AssistantChatMessage(content="ok"), notice]
assert incomplete_turn_user_text(done) is None
def test_truncated_turn_notice_keeps_the_partial_answer_retryable() -> None:
"""A kept partial answer is unfinished work, so retry continues it."""
tail = [
UserChatMessage(content="do it"),
AssistantChatMessage(content="partial answer"),
truncated_turn_notice().to_message(),
]
assert incomplete_turn_user_text(tail) == "do it"
# The same tail without the truncation notice is a finished turn.
assert incomplete_turn_user_text(tail[:2]) is None
def test_truncated_turn_notice_tells_the_model_where_it_stopped() -> None:
notice = truncated_turn_notice()
assert notice.kind == "truncated"
assert notice.to_message().content.startswith("[plyngent notice: truncated]")
assert "max_tokens" in notice.body
# The dropped call and the kept answer are both spelled out.
assert "discarded" in notice.body
assert "kept in the transcript" in notice.body
assert "max_tokens" in notice.summary
+100 -1
View File
@@ -22,7 +22,7 @@ from plyngent.lmproto.openai_compatible.model import (
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Mapping
def _response(message: AssistantChatMessage) -> ChatCompletionResponse:
@@ -156,3 +156,102 @@ async def test_parallel_tool_calls_run_concurrently() -> None:
tool_msgs = [e for e in agent.messages if isinstance(e, ToolChatMessage)]
assert {m.content for m in tool_msgs} == {"a", "b"}
assert any(getattr(e, "content", None) == "done" for e in events)
async def test_same_file_writes_apply_in_call_order() -> None:
"""A batch's writes to one file run one at a time, in call order."""
order: list[str] = []
@tool(register=False, writes=("path",))
async def write_a(path: str) -> str:
order.append(f"a start {path}")
await asyncio.sleep(0) # an unserialized sibling would interleave here
order.append(f"a end {path}")
return "a"
@tool(register=False, writes=("path",))
async def write_b(path: str) -> str:
order.append(f"b start {path}")
await asyncio.sleep(0)
order.append(f"b end {path}")
return "b"
registry = ToolRegistry([write_a, write_b])
results = await asyncio.gather(
registry.execute("write_a", '{"path": "a.txt"}'),
registry.execute("write_b", '{"path": "./a.txt"}'),
)
assert results == ["a", "b"]
assert order == ["a start a.txt", "a end a.txt", "b start ./a.txt", "b end ./a.txt"]
async def test_different_files_still_write_concurrently() -> None:
"""Serialization is per file: two files of one batch still overlap."""
overlaps = 0
gate = asyncio.Event()
async def _rendezvous() -> None:
nonlocal overlaps
overlaps += 1
if overlaps == 2:
_ = gate.set()
_ = await asyncio.wait_for(gate.wait(), timeout=2.0)
@tool(register=False, writes=("path",))
async def write_a(path: str) -> str:
del path
await _rendezvous()
return "a"
@tool(register=False, writes=("path",))
async def write_b(path: str) -> str:
del path
await _rendezvous()
return "b"
registry = ToolRegistry([write_a, write_b])
results = await asyncio.gather(
registry.execute("write_a", '{"path": "a.txt"}'),
registry.execute("write_b", '{"path": "b.txt"}'),
)
assert results == ["a", "b"]
async def test_a_write_holds_its_file_across_the_confirm() -> None:
"""The file is locked before the prompt, so a sibling write cannot overtake it."""
order: list[str] = []
prompt_shown = asyncio.Event()
answered = asyncio.Event()
@tool(register=False, writes=("path",))
async def write_a(path: str) -> str:
del path
order.append("a")
return "a"
@tool(register=False, writes=("path",))
async def write_b(path: str) -> str:
del path
order.append("b")
return "b"
async def on_confirm(name: str, args: Mapping[str, object], reason: str) -> bool:
del args, reason
order.append(f"confirm {name}")
prompt_shown.set()
await answered.wait()
return True
def danger(name: str, args: Mapping[str, object]) -> str | None:
del args
return "overwrite" if name == "write_a" else None
registry = ToolRegistry([write_a, write_b], danger=danger, on_confirm=on_confirm)
first = asyncio.create_task(registry.execute("write_a", '{"path": "a.txt"}'))
await prompt_shown.wait()
second = asyncio.create_task(registry.execute("write_b", '{"path": "a.txt"}'))
await asyncio.sleep(0) # the second write waits for the file, not for the human
assert order == ["confirm write_a"]
answered.set()
assert await asyncio.gather(first, second) == ["a", "b"]
assert order == ["confirm write_a", "a", "b"]
+36 -8
View File
@@ -66,6 +66,29 @@ def test_ids_reuse_after_pop() -> None:
assert g3.items[0].id == "t3"
def test_render_numbers_follow_listing_order() -> None:
"""Numbers are listing positions (TOP group first), never the stored ids."""
stack = TodoStack()
root = stack.push_group(["root", "sibling"]) # stored ids t1, t2
_ = stack.push_group(["child"]) # stored id t3
listing = stack.render()
assert " [ ] 1. child" in listing
assert " [ ] 2. root" in listing
assert " [ ] 3. sibling" in listing
assert "t1" not in listing
assert "t3" not in listing
# update(1) is the first item the listing shows — the one stored as t3.
updated = stack.update(1, status="done")
assert updated.id == "t3"
assert updated.title == "child"
assert updated.status == "done"
assert root.items[0].status == "pending"
with pytest.raises(ValueError, match="no todo item 4"):
stack.update(4, status="done")
def test_push_is_group_not_per_task_stack() -> None:
"""Multi-title push is one group; pop removes the whole group."""
stack = TodoStack()
@@ -94,11 +117,16 @@ def test_dfs_breakdown_with_groups() -> None:
root = stack.push_group(["T1", "T2"])
children = stack.push_group(["T1.1", "T1.2"])
assert stack.depth == 2
stack.update(children.items[0].id, status="done")
stack.update(children.items[1].id, status="done")
# Numbers come from the listing: the pushed children are the TOP group → 1, 2.
stack.update(1, status="done")
stack.update(2, status="done")
assert [i.status for i in children.items] == ["done", "done"]
_ = stack.pop()
assert stack.depth == 1
stack.update(root.items[0].id, status="done")
# Children gone: the root group is the listing again → 1 is T1, 2 is T2.
stack.update(1, status="done")
assert root.items[0].status == "done"
assert root.items[1].status == "pending"
_ = stack.push_group(["T2.1"])
assert stack.depth == 2
assert stack.top_group is not None
@@ -119,14 +147,14 @@ def test_single_title_still_one_group() -> None:
def test_todo_stack_needs_review() -> None:
stack = TodoStack()
assert not stack.needs_review()
item = stack.push("work")
_ = stack.push("work")
stack.begin_turn()
# Non-empty + untouched → nag (open or hygiene).
assert stack.needs_review()
# Any todo_* access this turn suppresses end-of-turn nag, even with open items.
stack.mark_touched()
assert not stack.needs_review()
stack.update(item.id, status="done")
stack.update(1, status="done")
stack.begin_turn()
# Terminal-only stack: same gate (untouched only).
assert stack.needs_review()
@@ -212,7 +240,7 @@ def test_inject_todo_nag_strategies() -> None:
def test_todo_prompts_signal_undone_work() -> None:
stack = TodoStack()
item = stack.push("open work")
_ = stack.push("open work")
reminder = stack.turn_reminder_prompt()
assert "[TODO OPEN WORK]" in reminder
assert "Stack not empty" in reminder
@@ -223,9 +251,9 @@ def test_todo_prompts_signal_undone_work() -> None:
assert "[TODO OPEN WORK]" in review
assert "undone work" in review.lower() or "open item" in review.lower()
assert "open work" in review
assert "t1:open work" in review or "open work" in review
assert "1. open work" in review # the review embeds the numbered listing
stack.update(item.id, status="done")
stack.update(1, status="done")
terminal_review = stack.review_prompt()
assert "[TODO HYGIENE]" in terminal_review
assert "done/cancelled" in terminal_review
@@ -0,0 +1,273 @@
"""Aggregated soft confirm: one prompt for a round's mutating calls."""
from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.agent import (
BatchConfirmDecision,
ToolRegistry,
ToolTag,
tool,
)
from plyngent.agent.loop import _execute_tool_calls
from plyngent.lmproto.openai_compatible.model import AssistantFunctionTool, AssistantFunctionToolCall
from plyngent.tools.context import SessionState, bind_tool_context
from plyngent.tools.grants import has_grant
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from plyngent.agent import ToolConfirmRequest
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
def _danger(name: str, _args: Mapping[str, object]) -> str | None:
return f"mutate {name}"
async def test_loop_round_prompts_once_before_running_its_calls() -> None:
"""The loop aggregates a round's confirms and applies one answer to each call."""
batch_prompts: list[int] = []
single_prompts: list[str] = []
@tool(tags=ToolTag.LOCAL, register=False)
async def stamp(path: str) -> str:
return f"stamped {path}"
def on_batch(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
batch_prompts.append(len(requests))
return BatchConfirmDecision(allowed=True)
async def on_single(name: str, _args: Mapping[str, object], _reason: str) -> bool:
single_prompts.append(name)
return True
reg = ToolRegistry([stamp], danger=_danger, on_confirm=on_single, on_confirm_batch=on_batch)
tool_calls = [
AssistantFunctionToolCall(id=f"c{index}", function=AssistantFunctionTool(name="stamp", arguments=arguments))
for index, arguments in enumerate(['{"path": "a"}', '{"path": "b"}'])
]
messages: list[AnyChatMessage] = []
events = [event async for event in _execute_tool_calls(reg, tool_calls, messages)]
assert batch_prompts == [2]
assert single_prompts == []
assert [getattr(message, "content", None) for message in messages] == ["stamped a", "stamped b"]
assert len(events) == len(tool_calls) * 2 # call event + result event per call
async def test_round_asks_once_for_many_mutating_calls() -> None:
"""Three calls in one round share one prompt instead of three."""
ran: list[str] = []
batch_prompts: list[list[ToolConfirmRequest]] = []
single_prompts: list[str] = []
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
async def stamp(path: str) -> str:
ran.append(path)
return f"stamped {path}"
def on_batch(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
batch_prompts.append(list(requests))
return BatchConfirmDecision(allowed=True)
async def on_single(name: str, _args: Mapping[str, object], _reason: str) -> bool:
single_prompts.append(name)
return True
reg = ToolRegistry([stamp], danger=_danger, on_confirm=on_single, on_confirm_batch=on_batch)
calls = [("stamp", '{"path": "a"}'), ("stamp", '{"path": "b"}'), ("stamp", '{"path": "c"}')]
decisions = await reg.prepare_batch_confirm(calls)
assert all(decision is not None and decision.allowed for decision in decisions)
assert [request.args["path"] for request in batch_prompts[0]] == ["a", "b", "c"]
assert single_prompts == []
results = [
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
]
assert results == ["stamped a", "stamped b", "stamped c"]
assert ran == ["a", "b", "c"]
assert single_prompts == []
async def test_single_request_keeps_the_one_call_prompt() -> None:
"""One mutating call stays the plain per-call confirm."""
batch_prompts: list[int] = []
single_prompts: list[str] = []
@tool(tags=ToolTag.LOCAL, register=False)
async def stamp(path: str) -> str:
return f"stamped {path}"
def on_batch(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
batch_prompts.append(len(requests))
return BatchConfirmDecision(allowed=True)
async def on_single(name: str, _args: Mapping[str, object], _reason: str) -> bool:
single_prompts.append(name)
return True
reg = ToolRegistry([stamp], danger=_danger, on_confirm=on_single, on_confirm_batch=on_batch)
assert await reg.prepare_batch_confirm([("stamp", '{"path": "a"}')]) == [None]
assert batch_prompts == []
assert await reg.execute("stamp", '{"path": "a"}') == "stamped a"
assert single_prompts == ["stamp"]
async def test_calls_that_cannot_run_are_left_out_of_the_prompt() -> None:
"""Missing arguments, bad JSON and unknown tools never reach the user."""
batch_prompts: list[list[str]] = []
@tool(tags=ToolTag.LOCAL, register=False)
async def stamp(path: str, *, force: bool = False) -> str:
return f"stamped {path} ({force})"
def on_batch(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
batch_prompts.append([str(request.args.get("path")) for request in requests])
return BatchConfirmDecision(allowed=True)
reg = ToolRegistry([stamp], danger=_danger, on_confirm_batch=on_batch)
decisions = await reg.prepare_batch_confirm(
[
("stamp", "{}"), # missing ``path``
("stamp", "not json"),
("nope", "{}"),
("stamp", '{"path": "a"}'),
("stamp", '{"path": "b", "force": true}'),
]
)
assert decisions[:3] == [None, None, None]
assert all(decision is not None for decision in decisions[3:])
assert batch_prompts == [["a", "b"]]
missing = await reg.execute("stamp", "{}")
assert missing.startswith("error: `stamp` is missing required argument")
async def test_batch_deny_reaches_every_call_with_one_comment() -> None:
"""A deny stops the whole batch and hands the same comment to each call."""
ran: list[str] = []
@tool(tags=ToolTag.LOCAL, register=False)
async def stamp(path: str) -> str:
ran.append(path)
return f"stamped {path}"
def on_batch(_requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
return BatchConfirmDecision(allowed=False, comment="not these two")
reg = ToolRegistry([stamp], danger=_danger, on_confirm_batch=on_batch)
calls = [("stamp", '{"path": "a"}'), ("stamp", '{"path": "b"}')]
decisions = await reg.prepare_batch_confirm(calls)
results = [
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
]
assert all(result.startswith("error: tool 'stamp' denied by user confirm") for result in results)
assert all("user comment: not these two" in result for result in results)
assert ran == []
async def test_each_round_asks_again() -> None:
"""An answer covers one round: the next round's calls are asked again."""
batch_prompts: list[int] = []
@tool(tags=ToolTag.LOCAL, register=False)
async def stamp(path: str) -> str:
return f"stamped {path}"
def on_batch(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
batch_prompts.append(len(requests))
return BatchConfirmDecision(allowed=True)
reg = ToolRegistry([stamp], danger=_danger, on_confirm_batch=on_batch)
for first, second in (("a", "b"), ("c", "d")):
calls = [("stamp", f'{{"path": "{first}"}}'), ("stamp", f'{{"path": "{second}"}}')]
decisions = await reg.prepare_batch_confirm(calls)
for index, (name, arguments) in enumerate(calls):
await reg.execute(name, arguments, decision=decisions[index])
assert batch_prompts == [2, 2]
async def test_manual_hands_the_round_back_to_the_per_call_prompt() -> None:
"""``each`` answers nothing for the batch: every call is asked on its own."""
batch_prompts: list[int] = []
single_prompts: list[str] = []
@tool(tags=ToolTag.LOCAL, register=False)
async def stamp(path: str) -> str:
return f"stamped {path}"
def on_batch(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
batch_prompts.append(len(requests))
return BatchConfirmDecision(allowed=False, each=True)
async def on_single(name: str, _args: Mapping[str, object], _reason: str) -> bool:
single_prompts.append(name)
return True
reg = ToolRegistry([stamp], danger=_danger, on_confirm=on_single, on_confirm_batch=on_batch)
calls = [("stamp", '{"path": "a"}'), ("stamp", '{"path": "b"}')]
decisions = await reg.prepare_batch_confirm(calls)
assert decisions == [None, None]
assert [await reg.execute(name, arguments) for name, arguments in calls] == ["stamped a", "stamped b"]
assert batch_prompts == [2]
assert single_prompts == ["stamp", "stamp"]
async def test_without_a_batch_hook_every_call_prompts_on_its_own() -> None:
"""A host that installs no batch hook keeps the per-call prompts."""
single_prompts: list[str] = []
@tool(tags=ToolTag.LOCAL, register=False)
async def stamp(path: str) -> str:
return f"stamped {path}"
async def on_single(name: str, _args: Mapping[str, object], _reason: str) -> bool:
single_prompts.append(name)
return True
reg = ToolRegistry([stamp], danger=_danger, on_confirm=on_single)
calls = [("stamp", '{"path": "a"}'), ("stamp", '{"path": "b"}')]
assert await reg.prepare_batch_confirm(calls) == [None, None]
assert [await reg.execute(name, arguments) for name, arguments in calls] == ["stamped a", "stamped b"]
assert single_prompts == ["stamp", "stamp"]
async def test_batch_allow_records_the_trustable_grant() -> None:
"""A batch allow still carries the trust a single allow would record."""
single_prompts: list[str] = []
@tool(tags=ToolTag.LOCAL | ToolTag.TRUSTABLE, register=False)
async def trust_me(path: str) -> str:
return f"done {path}"
def on_batch(_requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
return BatchConfirmDecision(allowed=True)
async def on_single(name: str, _args: Mapping[str, object], _reason: str) -> bool:
single_prompts.append(name)
return True
session = SessionState(session_id=1)
reg = ToolRegistry(
[trust_me],
danger=_danger,
on_confirm=on_single,
on_confirm_batch=on_batch,
auto_bind_state=True,
session_state=session,
)
with bind_tool_context(session=session):
calls = [("trust_me", '{"path": "a"}'), ("trust_me", '{"path": "b"}')]
decisions = await reg.prepare_batch_confirm(calls)
for path, decision in zip(("a", "b"), decisions, strict=True):
assert await reg.execute("trust_me", f'{{"path": "{path}"}}', decision=decision) == f"done {path}"
assert has_grant(session, "trust_me")
# The TRUSTABLE grant, not the batch answer, keeps later calls unchecked.
assert await reg.execute("trust_me", '{"path": "c"}') == "done c"
assert single_prompts == []
+116 -18
View File
@@ -2,8 +2,16 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.agent import ErrorEvent, ReasoningDeltaEvent, TextDeltaEvent, ToolCallEvent, ToolResultEvent
from plyngent.agent import (
ErrorEvent,
ReasoningDeltaEvent,
TextDeltaEvent,
ToolCallEvent,
ToolResultEvent,
TruncatedEvent,
)
from plyngent.cli.display import (
_RUN_BATCH_CMD_LIMIT,
_clear_streamed_lines,
_line_count_for_clear,
_pretty_line_for,
@@ -174,28 +182,28 @@ async def test_pretty_todo_push(capsys: pytest.CaptureFixture[str]) -> None:
)
)
result = (
"pushed group (depth=1) items=[a1]\n"
"pushed group (depth=1) items=[T1]\n"
"(LIFO of groups: depth=1; TOP group = current breakdown level)\n"
"group d=0 TOP:\n"
" [ ] a1: T1"
" [ ] 1. T1"
)
await render_events(_aiter([call, _result(result)]))
out = capsys.readouterr().out
assert "* Todo Push:" in out
assert " [ ] a1: T1" in out
assert " [ ] 1. T1" in out
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"
stack = "(LIFO of groups: depth=1; TOP group = current breakdown level)\ngroup d=0 TOP:\n [ ] 1. 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}"),
_pretty_call("todo_update", '{"number": 1, "status": "done"}'),
_result(f"pushed group (depth=1) items=[T1]\n{stack}"),
_result(f"updated 1 → done: T1\n{stack}"),
]
)
)
@@ -206,14 +214,14 @@ async def test_pretty_todo_batch_merges_to_one_stack(capsys: pytest.CaptureFixtu
# 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
assert "updated 1 → 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"
stack = "group d=0 TOP:\n [ ] 1. T1"
await render_events(
_aiter(
[
@@ -470,12 +478,73 @@ async def test_pretty_run_argv_batch_complete(capsys: pytest.CaptureFixture[str]
assert "* Run batch (done)" in out
_BATCH_DONE_RESULT = (
"steps=2 ran=2 stop_on_error=true stopped_early=false\n"
"--- step 1 ---\nexit_code=0\n--- stdout ---\n--- stderr ---\n"
"--- step 2 ---\nexit_code=0\n--- stdout ---\n--- stderr ---\n"
"--- summary ---\nlast_exit=0\n"
)
async def test_pretty_run_argv_batch_lists_commands(capsys: pytest.CaptureFixture[str]) -> None:
"""The batch row leads with one ``$ command`` line per step."""
args = '{"steps": [{"argv": ["git", "status"]}, {"argv": ["git", "diff", "--stat"]}]}'
await render_events(_aiter([_pretty_call("run_argv_batch", args), _result(_BATCH_DONE_RESULT)]))
out = capsys.readouterr().out
assert "$ git status\n$ git diff --stat\n* Run batch (done)" in out
async def test_pretty_run_argv_batch_joins_pipe_chain(capsys: pytest.CaptureFixture[str]) -> None:
"""``pipe_out`` steps share one line; a step they do not feed keeps its own."""
args = (
'{"steps": [{"argv": ["grep", "-n", "foo", "a.py"], "pipe_out": true},'
' {"argv": ["wc", "-l"]}, {"argv": ["git", "status"]}]}'
)
await render_events(_aiter([_pretty_call("run_argv_batch", args), _result(_BATCH_DONE_RESULT)]))
out = capsys.readouterr().out
assert "$ grep -n foo a.py | wc -l\n$ git status\n* Run batch (done)" in out
async def test_pretty_run_argv_batch_elides_a_long_command(capsys: pytest.CaptureFixture[str]) -> None:
long_arg = "x" * 200
args = f'{{"steps": [{{"argv": ["echo", "{long_arg}"]}}]}}'
await render_events(_aiter([_pretty_call("run_argv_batch", args), _result(_BATCH_DONE_RESULT)]))
out = capsys.readouterr().out
command = f"echo {long_arg}"
line = next(text for text in out.splitlines() if text.startswith("$ "))
assert line == f"$ {command[:_RUN_BATCH_CMD_LIMIT]}…"
async def test_pretty_run_argv_batch_steps_as_json_string(capsys: pytest.CaptureFixture[str]) -> None:
"""``steps`` may arrive as a JSON string holding the array."""
args = '{"steps": "[{\\"argv\\": [\\"git\\", \\"status\\"]}]"}'
await render_events(_aiter([_pretty_call("run_argv_batch", args), _result(_BATCH_DONE_RESULT)]))
out = capsys.readouterr().out
assert "$ git status\n* Run batch (done)" in out
async def test_pretty_run_argv_batch_unreadable_steps_hide_commands(
capsys: pytest.CaptureFixture[str],
) -> None:
await render_events(
_aiter(
[
_pretty_call("run_argv_batch", '{"steps": "nope"}'),
_result("error: steps must be a JSON array: Expecting value"),
]
)
)
out = capsys.readouterr().out
assert "$ " not in out
assert "* Run batch (error: steps must be a JSON array" in out
async def test_pretty_fetch(capsys: pytest.CaptureFixture[str]) -> None:
result = (
"status=200\nmethod=GET\nfinal_url=https://example.com\ncontent_type=application/json\n"
"body_kind=json\nbytes=1229\ntruncated=false\nredirects=0\nsecurity=public\n--- body ---\n{...}\n"
)
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result(result)]))
await render_events(_aiter([_pretty_call("fetch_local", '{"url": "https://example.com"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Fetch GET https://example.com (200)" in out
assert "[tool]" not in out
@@ -486,7 +555,7 @@ async def test_pretty_fetch_error_status(capsys: pytest.CaptureFixture[str]) ->
"status=404\nmethod=GET\nfinal_url=https://example.com/nope\ncontent_type=text/html\n"
"body_kind=html\nbytes=45\ntruncated=false\nredirects=0\nsecurity=public\n--- body ---\nNot found\n"
)
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com/nope"}'), _result(result)]))
await render_events(_aiter([_pretty_call("fetch_local", '{"url": "https://example.com/nope"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Fetch GET https://example.com/nope (404)" in out
@@ -496,17 +565,37 @@ async def test_pretty_fetch_truncated(capsys: pytest.CaptureFixture[str]) -> Non
"status=200\nmethod=GET\nfinal_url=https://example.com\ncontent_type=text/plain\n"
"body_kind=text\nbytes=50000\ntruncated=true\nredirects=0\nsecurity=public\n--- body ---\n..."
)
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result(result)]))
await render_events(_aiter([_pretty_call("fetch_local", '{"url": "https://example.com"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Fetch GET https://example.com (200)" in out
async def test_pretty_fetch_error(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(
_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result("error: fetch failed: boom")])
_aiter(
[_pretty_call("fetch_local", '{"url": "https://example.com"}'), _result("error: fetch_local failed: boom")]
)
)
out = capsys.readouterr().out
assert "* Fetch GET https://example.com (error: fetch failed: boom)" in out
assert "* Fetch GET https://example.com (error: fetch_local failed: boom)" in out
async def test_pretty_remote_fetch(capsys: pytest.CaptureFixture[str]) -> None:
result = "title=Example article\nurl=https://example.com\nchars=1234\ntruncated=false\n--- content ---\n..."
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Fetch https://example.com (1234 chars)" in out
async def test_pretty_fetch_reads_a_legacy_local_row(capsys: pytest.CaptureFixture[str]) -> None:
# Rows written before the local tool was renamed carry the local status block.
result = (
"status=200\nmethod=GET\nfinal_url=https://example.com\ncontent_type=text/plain\n"
"body_kind=text\nbytes=12\ntruncated=false\nredirects=0\nsecurity=public\n--- body ---\nhi\n"
)
await render_events(_aiter([_pretty_call("fetch", '{"url": "https://example.com"}'), _result(result)]))
out = capsys.readouterr().out
assert "* Fetch https://example.com (200)" in out
async def test_pretty_web_search(capsys: pytest.CaptureFixture[str]) -> None:
@@ -867,13 +956,13 @@ async def test_pretty_vcs_kind_branch_and_diff(capsys: pytest.CaptureFixture[str
async def test_pretty_todo_list_pop_and_clear(capsys: pytest.CaptureFixture[str]) -> None:
stack = " [ ] a1: T1\n [x] a2: T2"
stack = " [ ] 1. T1\n [x] 2. T2"
await render_events(_aiter([_pretty_call("todo_list", "{}"), _result(stack)]))
out = capsys.readouterr().out
assert "* Todo List:" in out
assert " [ ] a1: T1" in out
assert " [ ] 1. T1" in out
await render_events(_aiter([_pretty_call("todo_pop", "{}"), _result(f"popped TOP group (a1:T1)\n{stack}")]))
await render_events(_aiter([_pretty_call("todo_pop", "{}"), _result(f"popped TOP group (T1)\n{stack}")]))
out = capsys.readouterr().out
assert "* Todo Pop:" in out
@@ -1195,3 +1284,12 @@ async def test_pretty_prefix_closed_before_error_line(
assert "* Read 'a.txt' \n" in out
assert "[error] source=tool boom\n" in out
assert "* Read 'a.txt' (error)\n" in out
async def test_render_truncated_round(capsys: pytest.CaptureFixture[str]) -> None:
"""A round cut off at the output limit says so, and how the turn goes on."""
await render_events(_aiter([TruncatedEvent(kept_chars=42, continued=True)]))
assert "[limit] output truncated (42 chars kept); continuing\n" in capsys.readouterr().out
await render_events(_aiter([TruncatedEvent(kept_chars=42, continued=False)]))
assert "[limit] output truncated (42 chars kept); /retry continues from here\n" in capsys.readouterr().out
+42 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from pathlib import Path
import click
import pytest
from click.testing import CliRunner
@@ -9,6 +10,7 @@ from plyngent.cli.app import main
from plyngent.cli.editor import (
ensure_config_file,
get_editor,
open_directory,
open_in_editor,
prompt_edit_config,
resolve_config_path,
@@ -70,7 +72,8 @@ def test_open_in_editor_system_fallback(
path = tmp_path / "x.toml"
opened: list[str] = []
def fake_system(p: Path) -> None:
def fake_system(p: Path, *, hint: str = "") -> None:
assert hint
opened.append(str(p))
monkeypatch.setattr("plyngent.cli.editor._open_with_system_default", fake_system)
@@ -80,6 +83,43 @@ def test_open_in_editor_system_fallback(
assert path.is_file()
def test_open_directory_uses_the_system_opener_even_with_an_editor(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
opened: list[Path] = []
def fake_system(p: Path, *, hint: str = "") -> None:
assert hint
opened.append(p)
# A folder is not an editor target: $EDITOR must not be consulted.
monkeypatch.setenv("EDITOR", "codium --wait")
monkeypatch.setattr("plyngent.cli.editor._open_with_system_default", fake_system)
directory = tmp_path / "acme-deploy"
directory.mkdir()
open_directory(directory)
assert opened == [directory]
def test_open_directory_names_the_printed_path_when_the_opener_is_missing(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
def missing(*args: object, **kwargs: object) -> object:
del args, kwargs
msg = "xdg-open"
raise FileNotFoundError(msg)
monkeypatch.setattr("sys.platform", "linux")
monkeypatch.setattr("plyngent.cli.editor.subprocess.run", missing)
with pytest.raises(click.ClickException, match=r"xdg-open not found; open the printed path"):
open_directory(tmp_path)
def test_open_in_editor_no_fallback_requires_editor(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
@@ -227,7 +267,7 @@ def test_config_edit_system_fallback_message(
monkeypatch.delenv("VISUAL", raising=False)
monkeypatch.setattr(
"plyngent.cli.editor._open_with_system_default",
lambda p: None,
lambda p, **kwargs: None,
)
runner = CliRunner()
result = runner.invoke(main, ["config", "edit", "--config", str(path)])
@@ -0,0 +1,84 @@
"""Aggregated soft-confirm prompt: one box for a round's tool calls."""
from __future__ import annotations
from plyngent.agent import BatchConfirmDecision, ToolConfirmRequest
from plyngent.cli.limits import (
_prompt_confirm_batch_sync,
format_batch_confirm_box,
format_tool_confirm_box,
prompt_confirm_batch_async,
)
from plyngent.prompting import NonInteractiveBackend, temporary_backend
from tests.test_prompting import ScriptedBackend
def _requests() -> list[ToolConfirmRequest]:
return [
ToolConfirmRequest(name="delete_path", args={"path": "a.txt"}, reason="delete path 'a.txt'"),
ToolConfirmRequest(name="move_path", args={"src": "b", "dst": "c"}, reason="move 'b' → 'c'"),
]
def test_batch_box_numbers_every_call() -> None:
box = format_batch_confirm_box(_requests())
lines = box.splitlines()
assert "confirm · 2 tool calls" in lines[1]
assert any("[delete_path] delete path 'a.txt'" in line for line in lines)
assert any("[move_path] move 'b' → 'c'" in line for line in lines)
# Numbering keeps the listed order visible when the user answers.
assert any(line.startswith("│ 1. ") for line in lines)
assert any(line.startswith("│ 2. ") for line in lines)
def test_batch_box_indents_continuation_lines() -> None:
box = format_batch_confirm_box(
[
ToolConfirmRequest(name="run_argv", args={"argv": ["python", "-c", "x"]}, reason="argv\n body"),
ToolConfirmRequest(name="delete_path", args={"path": "a"}, reason="delete path 'a'"),
]
)
assert any(line.startswith("│ ") and "body" in line for line in box.splitlines())
def test_single_call_box_is_unchanged() -> None:
box = format_tool_confirm_box("delete_path", "delete path 'a.txt'")
assert "confirm · tool 'delete_path'" in box.splitlines()[1]
def test_batch_prompt_allows_every_call_on_yes() -> None:
with temporary_backend(ScriptedBackend(["y"])):
decision = _prompt_confirm_batch_sync(_requests())
assert decision == BatchConfirmDecision(allowed=True)
def test_batch_prompt_hands_the_round_to_the_per_call_prompt_on_manual() -> None:
with temporary_backend(ScriptedBackend(["m"])):
decision = _prompt_confirm_batch_sync(_requests())
assert decision.each is True
assert decision.allowed is False
def test_batch_prompt_denies_with_a_comment() -> None:
with temporary_backend(ScriptedBackend(["", "keep those files"])):
decision = _prompt_confirm_batch_sync(_requests())
assert decision.allowed is False
assert decision.comment == "keep those files"
def test_batch_prompt_defaults_to_deny() -> None:
with temporary_backend(ScriptedBackend([])):
decision = _prompt_confirm_batch_sync(_requests())
assert decision == BatchConfirmDecision(allowed=False)
def test_batch_prompt_denies_when_not_interactive() -> None:
with temporary_backend(NonInteractiveBackend()):
decision = _prompt_confirm_batch_sync(_requests())
assert decision == BatchConfirmDecision(allowed=False)
async def test_batch_prompt_async_allows() -> None:
with temporary_backend(ScriptedBackend(["yes"])):
decision = await prompt_confirm_batch_async(_requests())
assert decision == BatchConfirmDecision(allowed=True)
+4 -4
View File
@@ -149,7 +149,7 @@ def test_completer_help_commands(tmp_path: object, monkeypatch: object) -> None:
found.append(item)
index += 1
assert "compact" in found
assert "clear" in found
assert "config" in found
def test_completer_stream_on_off(tmp_path: object, monkeypatch: object) -> None:
@@ -194,9 +194,9 @@ def test_complete_slash_args_from_registry(tmp_path: object) -> None:
state.todo_stack = TodoStack()
_ = state.todo_stack.push_group(["alpha", "beta"])
ids = complete_slash_args(state, "/todos", "t", prior_args=["done"])
assert "t1" in ids
assert "t2" in ids
numbers = complete_slash_args(state, "/todos", "", prior_args=["done"])
assert numbers == ["1", "2"]
assert complete_slash_args(state, "/todos", "2", prior_args=["done"]) == ["2"]
state.remember_session_ids([3, 12, 40])
assert complete_slash_args(state, "/resume", "1") == ["12"]
+24 -4
View File
@@ -87,11 +87,8 @@ async def state(tmp_path: Path) -> AsyncIterator[ReplState]:
await memory.close()
async def test_help_and_clear(state: ReplState) -> None:
async def test_help(state: ReplState) -> None:
assert await handle_slash(state, "/help") is True
state.agent.messages.append(AssistantChatMessage(content="x"))
assert await handle_slash(state, "/clear") is True
assert state.agent.messages == []
async def test_auto_continue_limits_installs_on_limit(tmp_path: Path) -> None:
@@ -760,3 +757,26 @@ async def test_btw_read_notice_states_read_only_scope(
assert "read-only" in notice.body
assert "not saved" in notice.body
assert "[notice] aside: read tools" in capsys.readouterr().out
async def test_todos_update_takes_a_listing_number(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
assert await handle_slash(state, "/todos push first; second") is True
out = capsys.readouterr().out
assert "pushed group depth=1 items=[first, second]" in out
assert " [ ] 1. first" in out
assert " [ ] 2. second" in out
assert "t1" not in out
assert await handle_slash(state, "/todos done 2") is True
out = capsys.readouterr().out
assert "updated 2 → done" in out
assert " [x] 2. second" in out
# The stored id is not an address: only listing numbers are.
assert await handle_slash(state, "/todos done t1") is True
assert "takes an item number from the list" in capsys.readouterr().out
assert await handle_slash(state, "/todos done 7") is True
assert "no todo item 7" in capsys.readouterr().out
assert await handle_slash(state, "/todos pop") is True
assert "popped TOP group (first, second)" in capsys.readouterr().out
+80 -46
View File
@@ -11,6 +11,7 @@ from plyngent.cli.slash import handle_slash
from plyngent.cli.state import ReplState
from plyngent.config.models import DatabaseConfig, OpenAIProvider
from plyngent.config.store import ConfigStore
from plyngent.lmproto.openai_compatible.model import DeveloperChatMessage
from plyngent.memory import MemoryStore
from plyngent.prompting import NonInteractiveBackend, temporary_backend
from plyngent.skills import SKILL_FILE
@@ -31,6 +32,15 @@ def _write_skill(root: Path, name: str, *, description: str = "A test skill", bo
return directory
def _skill_notice_rows(state: ReplState) -> list[str]:
"""Body of every ``skills`` host notice in the session transcript."""
return [
message.content
for message in state.agent.messages
if isinstance(message, DeveloperChatMessage) and message.content.startswith("[plyngent notice: skills]")
]
def _config_doc(tmp_path: Path, *, paths: list[Path] | None = None, extra: str = "") -> tomlkit.TOMLDocument:
lines = ["[skills]", "discover = []"]
if paths:
@@ -99,7 +109,7 @@ async def test_skills_are_discovered_from_a_configured_root(tmp_path: Path) -> N
await state.memory.close()
async def test_slash_skills_lists_search_and_reads(
async def test_slash_skills_lists_and_searches(
skills_state: ReplState,
capsys: pytest.CaptureFixture[str],
) -> None:
@@ -112,14 +122,56 @@ async def test_slash_skills_lists_search_and_reads(
assert await handle_slash(skills_state, "/skills search pdftotext") is True
assert "acme-deploy/SKILL.md:" in capsys.readouterr().out
assert await handle_slash(skills_state, "/skills read acme-deploy") is True
assert "description: Deploy the acme stack" in capsys.readouterr().out
assert await handle_slash(skills_state, "/skills read acme-deploy --file SKILL.md") is True
assert "acme-deploy" in capsys.readouterr().out
async def test_slash_skills_open_reveals_the_skill_directory(
skills_state: ReplState,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
opened: list[Path] = []
monkeypatch.setattr("plyngent.cli.editor._open_with_system_default", lambda path, **kw: opened.append(path))
assert await handle_slash(skills_state, "/skills open acme-deploy") is True
assert skills_state.skills is not None
skill = skills_state.skills.find("acme-deploy")
assert skill is not None
assert opened == [skill.path]
assert f"opened {skill.path}" in capsys.readouterr().out
async def test_slash_skills_reload_picks_up_a_new_skill(
async def test_slash_skills_open_falls_back_to_the_printed_path(
skills_state: ReplState,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
import click
def no_opener(path: Path, **kwargs: object) -> None:
del path, kwargs
msg = "xdg-open not found"
raise click.ClickException(msg)
monkeypatch.setattr("plyngent.cli.editor._open_with_system_default", no_opener)
assert await handle_slash(skills_state, "/skills open acme-deploy") is True
assert skills_state.skills is not None
skill = skills_state.skills.find("acme-deploy")
assert skill is not None
out = capsys.readouterr().out
assert "error: xdg-open not found" in out
assert f"skill directory: {skill.path}" in out
async def test_slash_skills_open_reports_an_unknown_skill(
skills_state: ReplState,
capsys: pytest.CaptureFixture[str],
) -> None:
assert await handle_slash(skills_state, "/skills open nope") is True
assert "unknown skill 'nope'" in capsys.readouterr().out
async def test_slash_skills_sees_a_skill_added_while_chatting(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
@@ -130,8 +182,7 @@ async def test_slash_skills_reload_picks_up_a_new_skill(
assert await handle_slash(state, "/skills") is True
assert "skills: 0" in capsys.readouterr().out
_ = _write_skill(root, "added-later")
assert await handle_slash(state, "/skills reload") is True
assert "skills reloaded: 1 visible" in capsys.readouterr().out
# No reload: the next listing reads the filesystem.
assert await handle_slash(state, "/skills") is True
assert "added-later" in capsys.readouterr().out
finally:
@@ -150,7 +201,7 @@ async def test_slash_skills_reports_a_disabled_feature(tmp_path: Path, capsys: p
async def test_slash_skills_requires_a_target(skills_state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
for command in ("/skills search", "/skills read"):
for command in ("/skills search", "/skills open"):
assert await handle_slash(skills_state, command) is True
assert "requires" in capsys.readouterr().err
@@ -189,61 +240,44 @@ async def test_skill_tools_are_absent_when_disabled(tmp_path: Path) -> None:
await state.memory.close()
async def test_skills_catalog_is_folded_into_the_system_prompt(tmp_path: Path) -> None:
async def test_skill_tools_note_teaches_the_tools_without_listing_skills(tmp_path: Path) -> None:
root = tmp_path / "ours"
root.mkdir()
_ = _write_skill(root, "acme-deploy", description="Deploy the acme stack")
state = await _state(tmp_path, skills_dir=root, tools_enabled=True)
try:
prompt = state.agent.system_prompt or ""
assert "### Skills" in prompt
assert "- acme-deploy (config-user): Deploy the acme stack" in prompt
assert "`skill_read`" in prompt
# The body stays on disk: only the catalog goes into the prompt.
# Nothing about the skill set is injected: not the names, not the bodies.
assert "acme-deploy" not in prompt
assert "Deploy the acme stack" not in prompt
assert "Body." not in prompt
notes = _skill_notice_rows(state)
assert len(notes) == 1
assert "`skill_list`" in notes[0]
assert "`skill_search`" in notes[0]
# The note belongs to the session, not the event: a second push is a no-op.
await state._notice_skill_tools()
assert len(_skill_notice_rows(state)) == 1
finally:
await state.memory.close()
async def test_skills_catalog_respects_the_cap(tmp_path: Path) -> None:
root = tmp_path / "ours"
root.mkdir()
for name in ("alpha", "beta", "gamma"):
_ = _write_skill(root, name)
state = await _state(tmp_path, skills_dir=root, tools_enabled=True, extra="max_catalog_skills = 1")
try:
prompt = state.agent.system_prompt or ""
assert "alpha" in prompt
assert "beta" not in prompt
assert "… and 2 more" in prompt
finally:
await state.memory.close()
async def test_skills_catalog_can_be_switched_off(tmp_path: Path) -> None:
async def test_skill_tools_note_needs_the_feature_and_tools(tmp_path: Path) -> None:
root = tmp_path / "ours"
root.mkdir()
_ = _write_skill(root, "acme")
state = await _state(tmp_path, skills_dir=root, tools_enabled=True, extra="inject_catalog = false")
disabled = await _state(tmp_path, skills_dir=root, tools_enabled=True, extra="enabled = false")
try:
assert "### Skills" not in (state.agent.system_prompt or "")
# The tools are still offered; only the prompt block is gone.
assert state.agent.tools is not None
assert state.agent.tools.get("skill_list") is not None
assert _skill_notice_rows(disabled) == []
finally:
await state.memory.close()
async def test_skills_catalog_needs_tools(tmp_path: Path) -> None:
root = tmp_path / "ours"
root.mkdir()
_ = _write_skill(root, "acme")
state = await _state(tmp_path, skills_dir=root, tools_enabled=False)
await disabled.memory.close()
no_tools = await _state(tmp_path, skills_dir=root, tools_enabled=False)
try:
assert state.agent.tools is None
assert "### Skills" not in (state.agent.system_prompt or "")
assert _skill_notice_rows(no_tools) == []
finally:
await state.memory.close()
await no_tools.memory.close()
async def test_slash_skills_notes_when_tools_are_off(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+22 -8
View File
@@ -7,7 +7,7 @@ from dataclasses import replace
from plyngent.config.models import DeepseekProvider
from plyngent.config.routing import (
EffectiveProvider,
deepseek_search_base_url,
deepseek_api_base_url,
local_web_search,
resolve_effective_provider,
wants_web_search,
@@ -46,8 +46,14 @@ def test_chat_and_responses_conventions_use_the_local_tool() -> None:
assert local_web_search(_effective(convention="anthropic")) is False
def test_without_provider_tools_there_is_no_search() -> None:
assert local_web_search(_effective(convention="responses", tools=[])) is False
def test_provider_tools_only_gate_the_hosted_search() -> None:
# ``search_sources`` owns the local tool; ``provider_tools`` owns the hosted
# one. Turning hosted tools off must not take local search with it.
assert local_web_search(_effective(convention="responses", tools=[])) is True
assert local_web_search(_effective(convention="anthropic", tools=[])) is True
# With hosted tools on, the Anthropic surface keeps the hosted search and
# the local tool stays out of the way.
assert local_web_search(_effective(convention="anthropic")) is False
def test_without_search_sources_there_is_no_local_tool() -> None:
@@ -55,12 +61,20 @@ def test_without_search_sources_there_is_no_local_tool() -> None:
# for a search (the hosted path on the Anthropic convention is unaffected).
assert _effective(convention="responses", sources=[]).search_sources == []
assert local_web_search(_effective(convention="responses", sources=[])) is False
assert _effective(convention="responses").search_sources == ["deepseek", "bing"]
assert _effective(convention="responses").search_sources == ["deepseek"]
def test_search_base_url_prefers_an_anthropic_endpoint() -> None:
assert deepseek_search_base_url(_effective(convention="responses")) == "https://api.deepseek.com/anthropic"
def test_api_base_url_drops_the_convention_and_version_paths() -> None:
# The Search API appends ``/v1/tools/search`` itself, so neither the
# Anthropic convention's ``/anthropic`` nor an OpenAI-compatible ``/v1``
# may survive in the base.
assert deepseek_api_base_url(_effective(convention="responses")) == "https://api.deepseek.com"
assert deepseek_api_base_url(_effective(convention="anthropic")) == "https://api.deepseek.com"
assert (
deepseek_search_base_url(_effective(convention="responses", url="https://gateway.example/anthropic"))
== "https://gateway.example/anthropic"
deepseek_api_base_url(_effective(convention="anthropic", url="https://gateway.example/api/anthropic"))
== "https://gateway.example/api"
)
assert (
deepseek_api_base_url(_effective(convention="responses", url="https://gateway.example/v1"))
== "https://gateway.example"
)
-6
View File
@@ -16,8 +16,6 @@ def test_skills_section_defaults(tmp_path: Path) -> None:
assert store.skills_config.paths == []
assert store.skills_config.discover == ["plyngent", "claude"]
assert store.skills_config.include_project_roots is True
assert store.skills_config.inject_catalog is True
assert store.skills_config.max_catalog_skills == 50
assert store.skills_config.allow_write is False
@@ -30,8 +28,6 @@ enabled = false
paths = ["~/team-skills", "/srv/skills"]
discover = ["plyngent"]
include_project_roots = false
inject_catalog = false
max_catalog_skills = 5
allow_write = true
""",
encoding="utf-8",
@@ -41,8 +37,6 @@ allow_write = true
assert cfg.paths == ["~/team-skills", "/srv/skills"]
assert cfg.discover == ["plyngent"]
assert cfg.include_project_roots is False
assert cfg.inject_catalog is False
assert cfg.max_catalog_skills == 5
assert cfg.allow_write is True
+49
View File
@@ -6,11 +6,14 @@ import pytest
from plyngent.lmproto.anthropic import AnthropicClient
from plyngent.lmproto.anthropic.config import AnthropicConfig
from plyngent.lmproto.anthropic.model import (
AnthropicAssistantMessage,
AnthropicContentBlockDelta,
AnthropicMessageResponse,
AnthropicMessagesParam,
AnthropicMessageStop,
AnthropicRawContentBlock,
AnthropicResponseText,
AnthropicTextContent,
AnthropicThinkingConfig,
AnthropicUsage,
AnthropicUserMessage,
@@ -126,6 +129,52 @@ def test_messages_param_keeps_the_thinking_type_on_the_wire() -> None:
assert data["thinking"] == {"type": "enabled", "budget_tokens": 16384}
def test_stream_block_keeps_the_provider_fields() -> None:
"""A streamed block carries what a rebuild puts back on the wire."""
result = msgspec.json.decode(
b'{"type":"web_search_tool_result","tool_use_id":"call_00_1",'
b'"content":[{"type":"web_search_result","encrypted_content":"sd9"}]}',
type=AnthropicRawContentBlock,
)
assert result.tool_use_id == "call_00_1"
assert result.content == [{"type": "web_search_result", "encrypted_content": "sd9"}]
thinking = msgspec.json.decode(
b'{"type":"thinking","thinking":"hmm","signature":"sig-1"}',
type=AnthropicRawContentBlock,
)
assert (thinking.thinking, thinking.signature) == ("hmm", "sig-1")
def test_assistant_message_encodes_provider_blocks_verbatim() -> None:
"""An echoed block keeps its own keys — ``encrypted_content`` included."""
param = AnthropicMessagesParam(
model="deepseek-flash",
messages=[
AnthropicAssistantMessage(
content=[
{"type": "thinking", "thinking": "hmm", "signature": "sig-1"},
{
"type": "web_search_tool_result",
"tool_use_id": "call_00_1",
"content": [{"type": "web_search_result", "encrypted_content": "sd9"}],
},
AnthropicTextContent(text="done"),
]
)
],
)
data = msgspec.json.decode(msgspec.json.encode(param))
assert data["messages"][0]["content"] == [
{"type": "thinking", "thinking": "hmm", "signature": "sig-1"},
{
"type": "web_search_tool_result",
"tool_use_id": "call_00_1",
"content": [{"type": "web_search_result", "encrypted_content": "sd9"}],
},
{"type": "text", "text": "done"},
]
async def test_client_messages_create(monkeypatch: pytest.MonkeyPatch) -> None:
client = AnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://example/v1"))
+31
View File
@@ -5,11 +5,13 @@ from __future__ import annotations
from collections.abc import Sequence
import msgspec
from msgspec import UNSET
from plyngent.lmproto.deepseek.openai_compat.model import ChatCompletionsParam as DeepseekChatCompletionsParam
from plyngent.lmproto.openai_compatible.compat import (
coerce_chat_completions_param,
coerce_chat_completions_param_any,
coerce_chat_messages,
coerce_developer_messages_to_system,
developer_to_system_message,
)
@@ -88,3 +90,32 @@ def test_generic_coercion_handles_deepseek_runtime_param() -> None:
coerced = coerce_chat_completions_param_any(base)
raw = msgspec.json.decode(msgspec.json.encode(coerced))
assert raw["messages"][0]["role"] == "system"
def test_coerce_chat_messages_drops_provider_blocks() -> None:
"""Another surface's opaque blocks are not a chat-completions key."""
messages: list[AnyChatMessage] = [
UserChatMessage(content="hi"),
AssistantChatMessage(
content="done",
reasoning_content="hmm",
provider_blocks=[{"type": "thinking", "thinking": "hmm", "signature": "sig-1"}],
),
]
coerced = coerce_chat_messages(messages)
assistant = coerced[1]
assert isinstance(assistant, AssistantChatMessage)
assert assistant.provider_blocks is UNSET
# The reasoning slot is the provider-neutral one and stays.
assert assistant.reasoning_content == "hmm"
raw = msgspec.json.decode(msgspec.json.encode(ChatCompletionsParam(model="m", messages=coerced)))
assert "provider_blocks" not in raw["messages"][1]
# The history the copy came from keeps them for the surface that made them.
original = messages[1]
assert isinstance(original, AssistantChatMessage)
assert original.provider_blocks is not UNSET
def test_coerce_chat_messages_leaves_plain_history_alone() -> None:
messages: list[AnyChatMessage] = [UserChatMessage(content="hi"), AssistantChatMessage(content="done")]
assert coerce_chat_messages(messages) == messages
@@ -1,115 +0,0 @@
"""DeepSeek server-side search backend (Anthropic-compatible endpoint)."""
from __future__ import annotations
import msgspec
import pytest
from plyngent.lmproto.anthropic.config import AnthropicConfig
from plyngent.lmproto.deepseek.anthropic.search import (
DeepseekSearchClient,
SearchHit,
hits_from_content,
)
def test_hits_from_content_dedupes_and_skips_noise() -> None:
content = [
{"type": "thinking", "thinking": "search"},
{
"type": "web_search_tool_result",
"tool_use_id": "call_1",
"content": [
{"type": "web_search_result", "title": "First", "url": "https://a.example/x"},
{"type": "web_search_result", "title": "First again", "url": "https://a.example/x"},
{"type": "web_search_result", "url": "https://b.example/y"},
{"type": "web_search_result", "title": "no url"},
],
},
{
"type": "web_search_tool_result",
"tool_use_id": "call_2",
"content": [{"type": "web_search_tool_result_error", "error_code": "max_uses_exceeded"}],
},
]
hits = hits_from_content(content)
assert hits == [
SearchHit(title="First", url="https://a.example/x"),
SearchHit(title="", url="https://b.example/y"),
]
assert hits_from_content("not a list") == []
class _Resp:
def __init__(self, status_code: int, body: bytes) -> None:
self.status_code = status_code
self.content = body
def _search_body() -> bytes:
return msgspec.json.encode(
{
"id": "msg_1",
"content": [
{
"type": "server_tool_use",
"id": "call_00_1",
"name": "web_search",
"input": {"query": "DeepSeek-V4"},
},
{
"type": "web_search_tool_result",
"tool_use_id": "call_00_1",
"content": [
{
"type": "web_search_result",
"title": "DeepSeek-V4 preview",
"url": "https://api-docs.deepseek.com/news/news260424/",
"encrypted_content": "opaque",
}
],
},
],
"usage": {"input_tokens": 9932, "output_tokens": 377},
}
)
@pytest.mark.asyncio
async def test_search_sends_the_hosted_tool_and_reads_its_hits(monkeypatch: pytest.MonkeyPatch) -> None:
client = DeepseekSearchClient(AnthropicConfig(api_key="sk-test", base_url="https://api.deepseek.com/anthropic"))
sent: dict[str, object] = {}
async def fake_post(path: str, **kwargs: object) -> _Resp:
sent["path"] = path
sent.update(kwargs)
return _Resp(200, _search_body())
monkeypatch.setattr(client.session, "post", fake_post)
hits = await client.search("DeepSeek-V4", model="deepseek-flash")
assert hits == [SearchHit(title="DeepSeek-V4 preview", url="https://api-docs.deepseek.com/news/news260424/")]
assert sent["path"] == "/messages"
assert sent["stream"] is False
data = sent["data"]
assert isinstance(data, bytes)
payload = msgspec.json.decode(data, type=dict[str, object])
assert payload["tools"] == [{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}]
# A search turn stops as soon as it has searched; the answer is not needed.
assert payload["max_tokens"] == 512
assert payload["messages"] == [{"role": "user", "content": "DeepSeek-V4"}]
headers = sent["headers"]
assert isinstance(headers, dict)
assert headers["x-api-key"] == "sk-test"
@pytest.mark.asyncio
async def test_search_raises_with_the_endpoint_detail(monkeypatch: pytest.MonkeyPatch) -> None:
client = DeepseekSearchClient(AnthropicConfig(api_key="sk-test", base_url="https://api.deepseek.com/anthropic"))
async def fake_post(path: str, **kwargs: object) -> _Resp:
return _Resp(422, b'{"error": {"message": "tools[0]: unknown variant"}}')
monkeypatch.setattr(client.session, "post", fake_post)
with pytest.raises(RuntimeError, match="unknown variant"):
_ = await client.search("DeepSeek-V4", model="deepseek-flash")
@@ -0,0 +1,246 @@
"""DeepSeek's Search and Fetch APIs (``/v1/tools/search``, ``/v1/tools/fetch``)."""
from __future__ import annotations
import msgspec
import pytest
from plyngent.lmproto.deepseek.webtools import (
DeepseekWebToolsClient,
SearchRequest,
)
from plyngent.lmproto.openai_compatible.config import OpenAIConfig
from plyngent.lmproto.search import SearchHit
class _Resp:
def __init__(self, status_code: int, body: bytes) -> None:
self.status_code = status_code
self.content = body
def _body(*results: dict[str, object]) -> bytes:
return msgspec.json.encode({"results": list(results)})
def _client() -> DeepseekWebToolsClient:
return DeepseekWebToolsClient(
OpenAIConfig(access_key_or_token="sk-test", base_url="https://api.deepseek.com"),
)
def _decode_request(data: object) -> dict[str, object]:
assert isinstance(data, bytes)
return msgspec.json.decode(data, type=dict[str, object])
@pytest.mark.asyncio
async def test_search_posts_the_query_and_maps_the_hits(monkeypatch: pytest.MonkeyPatch) -> None:
client = _client()
sent: dict[str, object] = {}
async def fake_post(path: str, **kwargs: object) -> _Resp:
sent["path"] = path
sent.update(kwargs)
return _Resp(
200,
_body(
{
"rerank_score": 2.4,
"title": " DeepSeek V4 released ",
"url": " https://example.com/v4 ",
"snippet": "Tool calling and long context.",
"summary": "A longer summary.",
"authority": 3,
},
{"title": "Unusable", "url": ""},
),
)
monkeypatch.setattr(client.session, "post", fake_post)
hits = await client.search("DeepSeek V4", count=5)
assert hits == [
SearchHit(
title="DeepSeek V4 released",
url="https://example.com/v4",
snippet="Tool calling and long context.",
)
]
assert sent["path"] == "/v1/tools/search"
assert sent["stream"] is False
payload = _decode_request(sent["data"])
assert payload == {"query": "DeepSeek V4", "count": 5}
@pytest.mark.asyncio
async def test_summary_stands_in_when_the_index_has_no_snippet(monkeypatch: pytest.MonkeyPatch) -> None:
client = _client()
async def fake_post(path: str, **kwargs: object) -> _Resp:
return _Resp(
200,
_body(
{
"title": "T",
"url": "https://example.com/1",
"snippet": " ",
"summary": " A 600 character\tsummary\nspread over lines. ",
}
),
)
monkeypatch.setattr(client.session, "post", fake_post)
hits = await client.search("q")
assert hits == [
SearchHit(title="T", url="https://example.com/1", snippet="A 600 character summary spread over lines.")
]
@pytest.mark.asyncio
async def test_long_summaries_are_cut_for_the_tool_line(monkeypatch: pytest.MonkeyPatch) -> None:
client = _client()
async def fake_post(path: str, **kwargs: object) -> _Resp:
return _Resp(200, _body({"title": "T", "url": "https://example.com/1", "summary": "x" * 500}))
monkeypatch.setattr(client.session, "post", fake_post)
hits = await client.search("q")
assert len(hits[0].snippet) == 200
assert hits[0].snippet.endswith("…")
@pytest.mark.asyncio
async def test_empty_results_are_a_normal_answer(monkeypatch: pytest.MonkeyPatch) -> None:
client = _client()
async def fake_post(path: str, **kwargs: object) -> _Resp:
return _Resp(200, _body())
monkeypatch.setattr(client.session, "post", fake_post)
assert await client.search("nothing matches this") == []
@pytest.mark.asyncio
async def test_count_is_clamped_to_the_documented_range(monkeypatch: pytest.MonkeyPatch) -> None:
client = _client()
seen: list[dict[str, object]] = []
async def fake_post(path: str, **kwargs: object) -> _Resp:
seen.append(_decode_request(kwargs["data"]))
return _Resp(200, _body())
monkeypatch.setattr(client.session, "post", fake_post)
_ = await client.search("q", count=0)
_ = await client.search("q", count=99)
assert [payload["count"] for payload in seen] == [1, 50]
@pytest.mark.asyncio
async def test_an_oversized_query_is_refused_before_the_request(monkeypatch: pytest.MonkeyPatch) -> None:
client = _client()
calls: list[object] = []
async def fake_post(path: str, **kwargs: object) -> _Resp:
calls.append(path)
return _Resp(200, _body())
monkeypatch.setattr(client.session, "post", fake_post)
with pytest.raises(ValueError, match="at most 128 characters"):
_ = await client.search("x" * 129)
assert calls == []
@pytest.mark.asyncio
async def test_the_endpoint_error_detail_is_raised(monkeypatch: pytest.MonkeyPatch) -> None:
client = _client()
async def fake_post(path: str, **kwargs: object) -> _Resp:
return _Resp(400, b'{"error": {"message": "\'count\' must be between 1 and 50"}}')
monkeypatch.setattr(client.session, "post", fake_post)
with pytest.raises(RuntimeError, match="must be between 1 and 50"):
_ = await client.search("q")
@pytest.mark.asyncio
async def test_a_retryable_status_is_tried_again(monkeypatch: pytest.MonkeyPatch) -> None:
client = _client()
monkeypatch.setattr("plyngent.lmproto.deepseek.webtools.client.RETRY_DELAYS", (0.0,))
statuses = [429, 200]
async def fake_post(path: str, **kwargs: object) -> _Resp:
return _Resp(statuses.pop(0), _body({"title": "T", "url": "https://example.com/1"}))
monkeypatch.setattr(client.session, "post", fake_post)
hits = await client.search("q")
assert [hit.url for hit in hits] == ["https://example.com/1"]
assert statuses == []
@pytest.mark.asyncio
async def test_a_balance_error_is_not_retried(monkeypatch: pytest.MonkeyPatch) -> None:
client = _client()
monkeypatch.setattr("plyngent.lmproto.deepseek.webtools.client.RETRY_DELAYS", (0.0,))
calls: list[int] = []
async def fake_post(path: str, **kwargs: object) -> _Resp:
calls.append(1)
return _Resp(402, b'{"error": {"message": "Insufficient Balance"}}')
monkeypatch.setattr(client.session, "post", fake_post)
with pytest.raises(RuntimeError, match="Insufficient Balance"):
_ = await client.search("q")
assert calls == [1]
def test_the_request_model_omits_what_was_not_set() -> None:
encoded = msgspec.json.encode(SearchRequest(query="q"))
assert msgspec.json.decode(encoded, type=dict[str, object]) == {"query": "q"}
@pytest.mark.asyncio
async def test_fetch_posts_the_url_and_returns_the_page(monkeypatch: pytest.MonkeyPatch) -> None:
client = _client()
sent: dict[str, object] = {}
async def fake_post(path: str, **kwargs: object) -> _Resp:
sent["path"] = path
sent.update(kwargs)
return _Resp(200, msgspec.json.encode({"title": "Example article", "content": "# Article\n\nBody."}))
monkeypatch.setattr(client.session, "post", fake_post)
page = await client.fetch("https://example.com/article")
assert page.title == "Example article"
assert page.content == "# Article\n\nBody."
assert sent["path"] == "/v1/tools/fetch"
assert _decode_request(sent["data"]) == {"url": "https://example.com/article"}
@pytest.mark.asyncio
async def test_fetch_rejects_a_url_the_endpoint_would_refuse(monkeypatch: pytest.MonkeyPatch) -> None:
client = _client()
calls: list[object] = []
async def fake_post(path: str, **kwargs: object) -> _Resp:
calls.append(path)
return _Resp(200, b"{}")
monkeypatch.setattr(client.session, "post", fake_post)
for url in ("file:///etc/passwd", "https://user:secret@example.com/", "https:///no-host", ""):
with pytest.raises(ValueError):
_ = await client.fetch(url)
assert calls == []
@pytest.mark.asyncio
async def test_fetch_surfaces_a_security_policy_rejection(monkeypatch: pytest.MonkeyPatch) -> None:
client = _client()
async def fake_post(path: str, **kwargs: object) -> _Resp:
return _Resp(403, b'{"error": {"message": "target rejected by security policy"}}')
monkeypatch.setattr(client.session, "post", fake_post)
with pytest.raises(RuntimeError, match="security policy"):
_ = await client.fetch("https://example.com/blocked")
+5 -5
View File
@@ -144,21 +144,21 @@ def test_broken_frontmatter_still_yields_a_listable_skill(tmp_path: Path) -> Non
assert any("frontmatter not parsed" in issue for issue in skill.issues)
def test_cache_picks_up_an_edit_and_reload_forces_a_rescan(tmp_path: Path) -> None:
def test_every_lookup_reads_the_filesystem(tmp_path: Path) -> None:
"""No cache: an edit or a skill added outside the chat is simply there."""
root = tmp_path / "skills"
directory = _write_skill(root, "cached", description="Before")
directory = _write_skill(root, "live", description="Before")
store = SkillStore(build_roots(SkillsConfig(discover=["plyngent"]), workspace=tmp_path, user_config_dir=tmp_path))
assert store.skills[0].description == "Before"
_ = (directory / SKILL_FILE).write_text(
"---\nname: cached\ndescription: After\n---\n\nBody.\n",
"---\nname: live\ndescription: After\n---\n\nBody.\n",
encoding="utf-8",
)
assert store.skills[0].description == "After"
_ = _write_skill(root, "added")
store.reload()
assert [s.name for s in store.skills] == ["added", "cached"]
assert [s.name for s in store.skills] == ["added", "live"]
def test_default_write_root_prefers_our_own_directory(tmp_path: Path) -> None:
+1 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import pytest
from plyngent.lmproto.deepseek.anthropic.search import SearchHit
from plyngent.lmproto.search import SearchHit
from plyngent.tools.net.bing import parse_bing_rss
DOCUMENT = """<?xml version="1.0" encoding="utf-8" ?>
+1 -1
View File
@@ -74,7 +74,7 @@ def test_read_only_classification() -> None:
"vcs_diff",
"vcs_log",
"vcs_branch",
"fetch",
"fetch_local",
"skill_list",
"skill_read",
"skill_search",
+7 -3
View File
@@ -24,14 +24,18 @@ def test_classify_write_overwrite_only(tmp_path: Path) -> None:
instance = InstanceState(workspace_root=tmp_path.resolve())
with bind_instance(instance):
_ = set_workspace_root(tmp_path)
# New file: no soft-confirm
# New file: no soft-confirm, with or without the overwrite flag
assert classify_danger("write_file", {"path": "new.txt"}) is None
assert classify_danger("write_file", {"path": "new.txt", "overwrite": True}) is None
# Partial edits: never soft-confirm
assert classify_danger("edit_replace", {"path": "x.txt"}) is None
assert classify_danger("edit_lineno", {"path": "x.txt", "start_line": 1, "end_line": 2}) is None
# Existing file write: confirm total overwrite
# Existing file: only an explicit overwrite asks; an unset/false flag is
# refused inside the tool, so no prompt may fire for it.
(tmp_path / "x.txt").write_text("old", encoding="utf-8")
reason = classify_danger("write_file", {"path": "x.txt"})
assert classify_danger("write_file", {"path": "x.txt"}) is None
assert classify_danger("write_file", {"path": "x.txt", "overwrite": False}) is None
reason = classify_danger("write_file", {"path": "x.txt", "overwrite": True})
assert reason is not None and "overwrite" in reason
(tmp_path / "dst.txt").write_text("d", encoding="utf-8")
(tmp_path / "src.txt").write_text("s", encoding="utf-8")
+22 -2
View File
@@ -49,6 +49,24 @@ def test_edit_lineno_error_shows_read_window(workspace: object) -> None:
assert "not read" in out
assert "read lines 2-3" in out
assert "margin" in out
# Ready to copy: 1-based line 1 is offset 0, and n=4 clamps the limit.
assert "read_file(path='j.txt', offset=0, limit=4, with_lineno=true)" in out
def test_edit_lineno_error_suggests_the_read_file_call(workspace: object) -> None:
"""Both rejections hand back a ready-to-copy call with a 0-based ``offset``."""
del workspace
_ = call_sync(write_file, "s.txt", "".join(f"line {i}\n" for i in range(1, 21)))
_ = reset_lineno_tracker()
_ = call_sync(read_file, "s.txt", offset=0, limit=10, with_lineno=True) # lines 1-10
# Line 15 was never shown; the hint reads it back with a margin (line 12 → offset 11).
out = call_sync(edit_lineno, "s.txt", 15, 15, "X\n")
assert "read_file(path='s.txt', offset=11, limit=7, with_lineno=true)" in out
# A stale read gets a fresh call for the same range after an external write.
_ = call_sync(write_file, "s.txt", "changed\n", overwrite=True)
out = call_sync(edit_lineno, "s.txt", 1, 1, "X\n")
assert "changed since it was read" in out
assert "read_file(path='s.txt', offset=0, limit=1, with_lineno=true)" in out
def test_edit_lineno_requires_all_lines_read(workspace: object) -> None:
@@ -84,6 +102,8 @@ def test_edit_lineno_append_requires_last_line_read(workspace: object) -> None:
_ = call_sync(read_file, "ap.txt", offset=0, limit=2, with_lineno=True)
out = call_sync(edit_lineno, "ap.txt", 4, 4, "d\n")
assert "not read" in out and "3" in out
# Appending needs the last line, so the suggested window ends at n.
assert "read_file(path='ap.txt', offset=0, limit=3, with_lineno=true)" in out
# Read the last line, then append succeeds.
_ = call_sync(read_file, "ap.txt", offset=2, limit=1, with_lineno=True)
out = call_sync(edit_lineno, "ap.txt", 4, 4, "d\n")
@@ -129,7 +149,7 @@ def test_edit_lineno_requires_rerun_after_write(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "f.txt", "one\ntwo\nthree\n")
_ = _read_lineno("f.txt")
_ = call_sync(write_file, "f.txt", "changed\n")
_ = call_sync(write_file, "f.txt", "changed\n", overwrite=True)
out = call_sync(edit_lineno, "f.txt", 1, 1, "x\n")
assert "changed" in out and "re-read" in out
@@ -162,7 +182,7 @@ def test_edit_lineno_detects_external_change(workspace: object) -> None:
_ = call_sync(write_file, "i.txt", "one\ntwo\nthree\n")
_ = _read_lineno("i.txt")
# Simulate an external/process-tool mutation: rewrite content + new mtime.
_ = call_sync(write_file, "i.txt", "one\ntwo\nthree\nCHANGED\n")
_ = call_sync(write_file, "i.txt", "one\ntwo\nthree\nCHANGED\n", overwrite=True)
os.utime(workspace / "i.txt", (time.time() + 1, time.time() + 1))
out = call_sync(edit_lineno, "i.txt", 1, 1, "x\n")
assert "changed since it was read" in out
@@ -1,4 +1,4 @@
"""Tests for tools.net.fetch (policy, UA, methods, private grants)."""
"""Tests for tools.net.fetch_local (policy, UA, methods, private grants)."""
from __future__ import annotations
@@ -10,7 +10,7 @@ import pytest
from plyngent.tools.context import read_only_context
from plyngent.tools.danger import classify_danger
from plyngent.tools.net import fetch, grant_private_host
from plyngent.tools.net import fetch_local, grant_private_host
from plyngent.tools.net.policy import (
DEFAULT_USER_AGENT,
FetchPolicyError,
@@ -30,7 +30,7 @@ if TYPE_CHECKING:
class _Handler(BaseHTTPRequestHandler):
"""Minimal echo server for fetch tests."""
"""Minimal echo server for fetch_local tests."""
@override
def log_message(self, format: str, *args: object) -> None:
@@ -171,10 +171,10 @@ def test_soft_confirm_reason_matrix() -> None:
def test_classify_danger_fetch() -> None:
assert classify_danger("fetch", {"url": "https://example.com/", "method": "GET"}) is None
reason = classify_danger("fetch", {"url": "http://example.com/", "method": "GET"})
assert classify_danger("fetch_local", {"url": "https://example.com/", "method": "GET"}) is None
reason = classify_danger("fetch_local", {"url": "http://example.com/", "method": "GET"})
assert reason is not None and "cleartext" in reason
reason2 = classify_danger("fetch", {"url": "https://example.com/api", "method": "DELETE"})
reason2 = classify_danger("fetch_local", {"url": "https://example.com/api", "method": "DELETE"})
assert reason2 is not None and "DELETE" in reason2
@@ -184,36 +184,36 @@ async def test_fetch_get_post_put_delete(workspace: Path, http_server: str) -> N
# Grant loopback for this process/instance (fixture binds InstanceState).
grant_private_host("127.0.0.1", int(base.rsplit(":", 1)[1]))
out = await call_async(fetch, f"{base}/hello")
out = await call_async(fetch_local, f"{base}/hello")
assert "status=200" in out
assert "GET /hello" in out
assert "security=cleartext-http" in out
out_post = await call_async(fetch, f"{base}/echo", method="POST", body="hi")
out_post = await call_async(fetch_local, f"{base}/echo", method="POST", body="hi")
assert "status=201" in out_post
assert "POST:hi" in out_post
out_put = await call_async(fetch, f"{base}/echo", method="PUT", body="x")
out_put = await call_async(fetch_local, f"{base}/echo", method="PUT", body="x")
assert "PUT:x" in out_put
out_del = await call_async(fetch, f"{base}/x", method="DELETE")
out_del = await call_async(fetch_local, f"{base}/x", method="DELETE")
assert "status=204" in out_del
async def test_fetch_read_only_context_blocks_non_get(workspace: Path, http_server: str) -> None:
"""In a read-only context fetch only allows GET; outside it POST works."""
"""In a read-only context fetch_local only allows GET; outside it POST works."""
del workspace
port = int(http_server.rsplit(":", 1)[1])
grant_private_host("127.0.0.1", port)
with read_only_context():
out = await call_async(fetch, f"{http_server}/hello")
out = await call_async(fetch_local, f"{http_server}/hello")
assert "status=200" in out # GET is fine in a read-only context
blocked = await call_async(fetch, f"{http_server}/echo", method="POST", body="hi")
blocked = await call_async(fetch_local, f"{http_server}/echo", method="POST", body="hi")
assert blocked.startswith("error:")
assert "GET" in blocked
out_post = await call_async(fetch, f"{http_server}/echo", method="POST", body="hi")
out_post = await call_async(fetch_local, f"{http_server}/echo", method="POST", body="hi")
assert "POST:hi" in out_post
@@ -222,18 +222,18 @@ async def test_fetch_user_agent_passthrough(workspace: Path, http_server: str) -
port = int(http_server.rsplit(":", 1)[1])
grant_private_host("127.0.0.1", port)
out = await call_async(fetch, f"{http_server}/ua", user_agent="AgentUA/9")
out = await call_async(fetch_local, f"{http_server}/ua", user_agent="AgentUA/9")
assert "AgentUA/9" in out
out2 = await call_async(
fetch,
fetch_local,
f"{http_server}/ua",
headers={"User-Agent": "HeaderUA/1"},
)
assert "HeaderUA/1" in out2
out3 = await call_async(
fetch,
fetch_local,
f"{http_server}/ua",
headers={"User-Agent": "HeaderUA/1"},
user_agent="ArgUA/2",
@@ -247,11 +247,11 @@ async def test_fetch_truncation_and_binary(workspace: Path, http_server: str) ->
port = int(http_server.rsplit(":", 1)[1])
grant_private_host("127.0.0.1", port)
out = await call_async(fetch, f"{http_server}/big", max_bytes=100)
out = await call_async(fetch_local, f"{http_server}/big", max_bytes=100)
assert "truncated=true" in out
assert "bytes=100" in out
out_bin = await call_async(fetch, f"{http_server}/bin")
out_bin = await call_async(fetch_local, f"{http_server}/bin")
assert "body_kind=binary" in out_bin
assert "binary body omitted" in out_bin
@@ -261,7 +261,7 @@ async def test_fetch_char_truncation_embeds_token(workspace: Path, http_server:
port = int(http_server.rsplit(":", 1)[1])
grant_private_host("127.0.0.1", port)
out = await call_async(fetch, f"{http_server}/big", max_chars=1000)
out = await call_async(fetch_local, f"{http_server}/big", max_chars=1000)
assert "truncated=true" in out
body = out.split("--- body ---", 1)[-1]
assert "truncate_token=" in body
@@ -281,7 +281,7 @@ async def test_fetch_offset_skips_body(workspace: Path, http_server: str) -> Non
grant_private_host("127.0.0.1", port)
# /big body is 5000 chars; offset 4900 leaves 100 chars, no token needed.
out = await call_async(fetch, f"{http_server}/big", max_chars=200, offset=4900)
out = await call_async(fetch_local, f"{http_server}/big", max_chars=200, offset=4900)
body = out.split("--- body ---", 1)[-1]
assert body.strip() == "x" * 100
assert "truncate_token=" not in out
@@ -290,21 +290,21 @@ async def test_fetch_offset_skips_body(workspace: Path, http_server: str) -> Non
async def test_fetch_private_denied_without_grant(workspace: Path, http_server: str) -> None:
del workspace
# No grant, no policy hook → hard deny (even though server is up).
out = await call_async(fetch, f"{http_server}/hello")
out = await call_async(fetch_local, f"{http_server}/hello")
assert out.startswith("error:")
assert "private" in out or "loopback" in out
async def test_fetch_forbidden_metadata(workspace: Path) -> None:
del workspace
out = await call_async(fetch, "http://169.254.169.254/latest/meta-data/")
out = await call_async(fetch_local, "http://169.254.169.254/latest/meta-data/")
assert out.startswith("error:")
assert "forbidden" in out
async def test_fetch_bad_scheme(workspace: Path) -> None:
del workspace
out = await call_async(fetch, "file:///etc/passwd")
out = await call_async(fetch_local, "file:///etc/passwd")
assert out.startswith("error:")
assert "scheme" in out
@@ -313,6 +313,6 @@ async def test_fetch_redirect_loop_capped(workspace: Path, http_server: str) ->
del workspace
port = int(http_server.rsplit(":", 1)[1])
grant_private_host("127.0.0.1", port)
out = await call_async(fetch, f"{http_server}/redirect-loop", max_redirects=3)
out = await call_async(fetch_local, f"{http_server}/redirect-loop", max_redirects=3)
assert out.startswith("error:")
assert "redirect" in out.lower()
+73
View File
@@ -0,0 +1,73 @@
"""The remote ``fetch`` tool (DeepSeek's Fetch API, Markdown pages)."""
from __future__ import annotations
import pytest
from plyngent.agent.tools import ToolTag
from plyngent.lmproto.deepseek.webtools import FetchResponse
from plyngent.tools.net.fetch_remote import FETCH_TOOL_NAME, build_fetch_tool
class _Client:
"""Stands in for DeepseekWebToolsClient.fetch."""
def __init__(self, *, page: FetchResponse | None = None, error: str | None = None) -> None:
self.page = page or FetchResponse(title="T", content="body")
self.error = error
self.urls: list[str] = []
async def fetch(self, url: str) -> FetchResponse:
self.urls.append(url)
if self.error is not None:
raise RuntimeError(self.error)
return self.page
def _tool(client: _Client, *, max_chars: int = 32_000):
return build_fetch_tool(client, default_max_chars=max_chars)
def test_definition_shape() -> None:
definition = _tool(_Client())
assert definition.name == FETCH_TOOL_NAME == "fetch"
assert definition.tags == ToolTag.LOCAL | ToolTag.READ_ONLY
properties = definition.parameters["properties"]
assert set(properties) == {"url", "offset", "max_chars"}
assert definition.parameters["required"] == ["url"]
assert "fetch_local" in definition.description
@pytest.mark.asyncio
async def test_handler_renders_title_url_and_content() -> None:
client = _Client(page=FetchResponse(title=" Example article ", content="# Heading\n\nBody."))
result = await _tool(client).handler(url="https://example.com/article")
assert client.urls == ["https://example.com/article"]
assert result.splitlines() == [
"title=Example article",
"url=https://example.com/article",
"chars=16",
"truncated=false",
"--- content ---",
"# Heading",
"",
"Body.",
]
@pytest.mark.asyncio
async def test_offset_and_truncation_keep_the_remainder_resumable() -> None:
client = _Client(page=FetchResponse(title="", content="0123456789"))
result = await _tool(client, max_chars=3).handler(url="https://example.com/", offset=5)
assert "title=(untitled)" in result
assert "--- content ---\n567" in result
assert "truncated=true" in result
assert "truncate_token=" in result
@pytest.mark.asyncio
async def test_handler_reports_a_client_error() -> None:
result = await _tool(_Client(error="HTTP 404: no usable content")).handler(url="https://example.com/x")
assert result == "error: fetch failed: HTTP 404: no usable content"
+47
View File
@@ -1,11 +1,18 @@
from __future__ import annotations
import asyncio
import importlib
from pathlib import Path
from typing import TYPE_CHECKING
from plyngent.agent import ToolRegistry
from plyngent.tools.danger import classify_danger
from plyngent.tools.file import edit_replace, listdir, read_file, write_file
from tests.test_tools.helpers import call_sync
if TYPE_CHECKING:
from collections.abc import Mapping
get_truncated_module = importlib.import_module("plyngent.tools.file.get_truncated")
@@ -28,6 +35,46 @@ def test_write_read_listdir_edit(workspace: object) -> None:
assert call_sync(read_file, "notes/a.txt") == "L1-1\nhello there"
def test_write_file_refuses_existing_file_without_overwrite(workspace: Path) -> None:
assert "wrote" in call_sync(write_file, "keep.txt", "first")
refused = call_sync(write_file, "keep.txt", "second")
assert refused == "error: file exists: keep.txt (set overwrite=true to replace it)"
assert (workspace / "keep.txt").read_text(encoding="utf-8") == "first"
assert "wrote" in call_sync(write_file, "keep.txt", "second", overwrite=True)
assert (workspace / "keep.txt").read_text(encoding="utf-8") == "second"
async def test_same_file_writes_in_one_batch_keep_call_order(workspace: Path) -> None:
"""The second call writes onto the first one's text, not onto the stale text."""
target = workspace / "notes.txt"
_ = target.write_text("one", encoding="utf-8")
confirmed = asyncio.Event()
async def on_confirm(name: str, args: Mapping[str, object], reason: str) -> bool:
del name, args, reason
await confirmed.wait()
return True
registry = ToolRegistry([write_file, edit_replace], danger=classify_danger, on_confirm=on_confirm)
rewrite = asyncio.create_task(
registry.execute("write_file", '{"path": "notes.txt", "content": "two", "overwrite": true}')
)
await asyncio.sleep(0) # the rewrite is waiting on its overwrite confirm
edit = asyncio.create_task(
registry.execute(
"edit_replace",
'{"path": "notes.txt", "old_string": "two", "new_string": "three"}',
)
)
await asyncio.sleep(0) # the edit waits for the file instead of editing stale text
assert target.read_text(encoding="utf-8") == "one"
confirmed.set()
rewrite_out, edit_out = await asyncio.gather(rewrite, edit)
assert "wrote" in rewrite_out
assert "replaced" in edit_out
assert target.read_text(encoding="utf-8") == "three"
def test_read_file_max_chars_embeds_token(workspace: Path) -> None:
(workspace / "big.txt").write_text("a" * 1000, encoding="utf-8")
out = call_sync(read_file, "big.txt", max_chars=200)
+2 -2
View File
@@ -86,9 +86,9 @@ async def test_get_truncated_file_missing(workspace: Path) -> None:
async def test_get_truncated_serves_a_fetch_body_without_refetching(monkeypatch) -> None:
"""A fetch body's token reads the stored remainder; the request is not repeated."""
from plyngent.tools.net.fetch import format_fetch_result
from plyngent.tools.net.fetch_local import format_fetch_result
fetch_module = importlib.import_module("plyngent.tools.net.fetch")
fetch_module = importlib.import_module("plyngent.tools.net.fetch_local")
async def _must_not_refetch(*_args: object, **_kwargs: object) -> str:
msg = "resuming a fetch body must not re-run the request"
+10 -16
View File
@@ -2,12 +2,10 @@
from __future__ import annotations
from typing import Any
import pytest
from plyngent.agent.tools import ToolTag
from plyngent.lmproto.deepseek.anthropic.search import SearchHit
from plyngent.lmproto.search import SearchHit
from plyngent.tools.net.search import (
WEB_SEARCH_TOOL_NAME,
build_web_search_tool,
@@ -93,25 +91,21 @@ async def test_handler_without_sources_says_so() -> None:
assert "no source configured" in await definition.handler(query="q")
def test_deepseek_backend_passes_the_model_through() -> None:
@pytest.mark.asyncio
async def test_deepseek_backend_asks_the_search_api() -> None:
from plyngent.tools.net.search import DeepseekSearchBackend
class _Client:
def __init__(self) -> None:
self.seen: list[tuple[str, str]] = []
self.queries: list[str] = []
async def search(self, query: str, *, model: str, **kwargs: object) -> list[SearchHit]:
self.seen.append((query, model))
async def search(self, query: str) -> list[SearchHit]:
self.queries.append(query)
return _hits(("T", "https://example.com"))
client: Any = _Client()
backend = DeepseekSearchBackend(client, model="deepseek-flash")
client = _Client()
backend = DeepseekSearchBackend(client) # type: ignore[arg-type] — structural stub
async def run() -> list[SearchHit]:
return list(await backend.search("q"))
import asyncio
hits = asyncio.run(run())
hits = await backend.search("q")
assert [hit.url for hit in hits] == ["https://example.com"]
assert client.seen == [("q", "deepseek-flash")]
assert client.queries == ["q"]
+27
View File
@@ -101,6 +101,33 @@ async def test_session_on_todo_change_fires() -> None:
assert hits == ["session"]
async def test_todo_update_uses_listing_numbers() -> None:
store = MemoryViewStore({})
session = SessionState(session_id="numbers", data=session_data_view(store=store))
registry = ToolRegistry(list(TODO_TOOLS), session_state=session)
out = await registry.execute("todo_push", '{"titles": ["root"]}')
assert "pushed group (depth=1) items=[root]" in out
out = await registry.execute("todo_push", '{"titles": ["child"]}')
assert "pushed group (depth=2) items=[child]" in out
# TOP group first; the stored ids stay out of every result.
assert " [ ] 1. child" in out
assert " [ ] 2. root" in out
assert "t1" not in out and "t2" not in out
numbered = await registry.execute("todo_update", '{"number": 2, "status": "done"}')
assert "updated 2 → done: root" in numbered
assert " [x] 2. root" in numbered
stale = await registry.execute("todo_update", '{"number": 9, "status": "done"}')
assert stale.startswith("error: no todo item 9")
shaped = await registry.execute("todo_update", '{"number": "first"}')
assert shaped.startswith("error: `todo_update` expects `number`")
popped = await registry.execute("todo_pop", "{}")
assert "popped TOP group (child); new top group=[root]" in popped
async def test_todo_without_session_errors() -> None:
registry = ToolRegistry(list(TODO_TOOLS))
out = await registry.execute("todo_list", "{}")
+8
View File
@@ -38,6 +38,14 @@ def test_escape_error_hints_request_tool(workspace: object) -> None:
_ = resolve_path("../outside")
@pytest.mark.parametrize("required", [AccessMode.READ, AccessMode.WRITE, AccessMode.EXEC])
def test_escape_error_names_the_mode_to_request(workspace: object, required: AccessMode) -> None:
"""The refusal carries the lowest access mode that would cover the call."""
del workspace
with pytest.raises(WorkspaceError, match=f"mode='{required.name.lower()}'"):
_ = resolve_path("../outside", required=required)
def test_access_mode_parse_and_covers() -> None:
assert parse_access_mode(" Read ") is AccessMode.READ
assert parse_access_mode("EXEC") is AccessMode.EXEC