yoagent 0.25.1

Simple, effective agent loop with tool execution and event streaming
Documentation
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

**yoagent** is a Rust library (crate) for building AI coding agents. It provides a core agent loop, multi-provider LLM streaming, built-in tools, MCP integration, and context management. Published to crates.io as `yoagent`.

## Build & Development Commands

```bash
cargo test --all-features            # Run all tests — what CI runs
cargo clippy --all-targets --all-features   # Lint — what CI runs (-Dwarnings)
cargo fmt                            # Auto-format code
cargo fmt -- --check                 # Check formatting (CI uses this)
RUSTDOCFLAGS="-Dwarnings" cargo doc --no-deps --all-features   # Doc links — what CI runs

cargo test <test_name>               # Run a single test by name
cargo test --test agent_test         # Run a specific test file
cargo run --example cli              # Run the interactive CLI example
cargo run --example basic            # Run the minimal example
```

**Pass `--all-features` when checking your work.** `openapi` and `gasp` are off
by default, and some targets exist only behind them: `gasp_emit` and
`llm_compaction_live` are both `required-features = ["gasp"]`, so plain
`cargo clippy --all-targets` skips them entirely and reports clean on code CI
will reject. This is not hypothetical — a breaking change to `CostConfig`
passed local clippy while `llm_compaction_live` no longer compiled.

CI (`RUSTFLAGS="-Dwarnings"`) treats all clippy warnings as errors. Integration tests in `tests/integration_anthropic.rs` require a live API key and are skipped by default.

## Architecture

### Core Loop Pattern

The central abstraction is a **stateless agent loop** (`agent_loop.rs`) driven by two traits:

- **`StreamProvider`** (`provider/traits.rs`) — streams LLM responses via SSE into an mpsc channel, returning a complete `Message`
- **`AgentTool`** (`types.rs`) — defines tool name/schema/execution; the primary extension point for custom tools

The loop: stream assistant response → extract tool calls → execute tools (parallel by default) → append results → repeat until `StopReason::Stop` with no follow-ups.

`agent_loop` and `agent_loop_continue` are **free functions**, not methods. The `Agent` struct (`agent.rs`) is an optional stateful wrapper that manages message history, tool registry, steering/follow-up queues, and provider selection. The `_with_sender` methods (`prompt_with_sender`, `prompt_messages_with_sender`, `continue_loop_with_sender`) accept a caller-provided `mpsc::UnboundedSender<AgentEvent>` for real-time event consumption on a separate task. Every run starts in one private `start_run` (spawned; `finish` joins it); the `_with_sender` methods then await `join_run`, whose cancellation `DropGuard` cancels the run if their future is dropped (it ends aborted, the next `finish` restores state). `finish`/`reset` await the `JoinHandle` in place and take it only once joined, so they are cancel-safe.

### Provider System

7 provider implementations behind `StreamProvider`. `Agent` holds a concrete provider directly; `ProviderRegistry` maps `ApiProtocol` → provider for registry-based dispatch (e.g. custom multi-protocol routers):

| Protocol | File | Covers |
|----------|------|--------|
| `AnthropicMessages` | `anthropic.rs` | Claude models |
| `OpenAiCompletions` | `openai_compat.rs` | OpenAI, Groq, Together, DeepSeek, Fireworks, Mistral, xAI, etc. (15+) |
| `OpenAiResponses` | `openai_responses.rs` | OpenAI Responses API (GPT-6 presets) |
| `AzureOpenAiResponses` | `azure_openai.rs` | Azure OpenAI v1 Responses (`{resource}/openai/v1/responses`) |
| `GoogleGenerativeAi` | `google.rs` | Gemini |
| `GoogleVertex` | `google_vertex.rs` | Vertex AI |
| `BedrockConverseStream` | `bedrock.rs` | Amazon Bedrock (ConverseStream) |

The Responses and Azure providers share one request builder + read loop (`responses_request.rs`: `build_request_body(config, protocol)`, `stream_response`) and one SSE parser (`responses_stream.rs`); the provider files hold only the URL, auth and Azure's legacy deployment override of `model`. Body extras: `prompt_cache_key` = `StreamConfig::cache_session_key()` (explicit `CacheConfig::session_key`, else derived from the system prompt; none when hints are off; no compat gate — it is a Responses field, Azure documents it too); `include: ["reasoning.encrypted_content"]` — and reasoning-item replay, both **opt-in** (`OpenAiCompat::encrypted_reasoning` / `ModelConfig::with_encrypted_reasoning`, default off, never run live) (`assistant_items(content, replay: Option<ApiProtocol>)`, `None` → no reasoning items, paired items without ids) — only when `ModelConfig::reasoning` or a reasoning effort is sent (non-reasoning models 400 on both; `openai_responses(id)` infers `reasoning` via `model::openai_id_is_reasoning`: false for `gpt-3*`/`gpt-4*`/`chatgpt-*`/`*-chat*`, true otherwise); `store` never sent (server default). A finished `reasoning` item (`response.output_item.done`; `added` may carry a partial copy) with `encrypted_content` is stored on its thinking block (summary block, else raw-text block, else a new empty block) as `redacted` = JSON `{id, summary, encrypted_content}` + `call_ids` (`call_id` → `fc_…`) / `message_id` (first `msg_…`) of the items after it up to the next reasoning item of any kind — stored or not, finished or only streamed; `reasoning_bounds` + `thinking_slots`, the item's own second block excluded (filled in `finish`; `ToolCall::provider_metadata` is not used — `tests/provider_metadata_guard.rs` keeps it Gemini-only, #57), `redacted_protocol` = the stream's protocol (OpenAI and Azure are distinct); replayed via `redacted_for(protocol)` as `{"type":"reasoning",id,summary,encrypted_content}` in place before the next output item, whose `id` is restored from those keys (OpenAI rejects a reasoning item without its paired item); items with no replayed reasoning before them get no id; a trailing reasoning item with no output after it in its turn is dropped; foreign/unknown-origin redacted data and plain thinking text are never sent. Tests: `tests/responses_request_test.rs` (exact request JSON, both providers), `tests/responses_stream_test.rs`; live: `tests/integration_openai_responses.rs` (`--ignored`, `OPENAI_API_KEY`, `YOAGENT_RESPONSES_MODEL` overrides the default `gpt-6-luna`).

Anthropic SSE (`anthropic.rs`): block `index` → position in `content` via a map, never padding, so a block type it does not surface (server tool blocks, `fallback`, anything new) leaves no placeholder and later blocks keep their deltas; unknown block/delta types `warn_once` per type by name, unparseable start/delta events are logged. `redacted_thinking` (`data` arrives whole at `content_block_start`, no delta type) → `Content::thinking_redacted(ApiProtocol::AnthropicMessages, data)` → replayed as `{"type":"redacted_thinking","data"}` in place (#197).

Bedrock is the one non-SSE provider: ConverseStream answers with binary `application/vnd.amazon.eventstream` frames, decoded by the private `provider/eventstream.rs` (bytes buffered across chunks; prelude + message CRC-32 verified with an in-house table, no dependency; all ten header types; checksum/malformed → `Other`, a body ending mid-frame → `Network`; pinned by aws-sdk-go's published test vectors). `bedrock.rs` dispatches on the `:message-type` / `:event-type` / `:exception-type` headers — payloads are the event structs themselves (no wrapper key), shapes per the AWS Bedrock Runtime API reference. Blocks accumulate per `contentBlockIndex`; a tool call is pushed with the `__partial_json` marker and only `contentBlockStop` finalizes it (`finalize_tool_arguments`), so an unclosed block is never runnable. Exceptions classify via their documented HTTP status (`throttlingException`/`serviceUnavailableException` → `RateLimited`; other 5xx stay `Api`). No `messageStop` (incl. empty body) or a transport error → `Network`, never an empty `Ok` — except once `messageStop` **and** `metadata` arrived: a later drop/truncation keeps the response (no re-billed retry). `messageStop` without `metadata` → zero usage + `warn!(usage_missing = true)` (same zero as other providers). Unsurfaced content (image/citation/toolResult, server_tool_use, unknown union members via `#[serde(flatten)] other`, unknown event types) is dropped with a once-per-block warning; an unknown `contentBlockStart` member creates no block, so the first delta types it. Reasoning replay: signed → `reasoningText`, `redactedContent` kept in `Content::Thinking::redacted` (base64, bytes concatenated across deltas; `redacted_protocol = BedrockConverseStream`) → `redactedContent`, only if it came from Bedrock (another API's redacted data is skipped, logged at debug); unsigned → `reasoningText {text}` with no signature key, except to a model id containing `claude` (signature-verifying), where it is skipped at debug (never `signature: ""`); the Anthropic provider skips Bedrock's redacted blocks. Signature deltas are appended. Tested only with mock frames (`tests/bedrock_stream_test.rs`). Auth (`resolve_auth`, first match wins): an `authorization` key in `ModelConfig.headers` (any case) → sent alone; non-empty `api_key` → `access:secret[:token]` = SigV4 (`ASIA…` without a token refused), no `:` = Bedrock API key (`Bearer`) after `checked_bearer` refuses whitespace/control chars, an `AKIA`/`ASIA` start, non-base64 chars and a bare 40-char secret (unknown prefix — not `bedrock-api-key-`/`ABSK` — only warns once); empty `api_key` → env read per request (type `Env` = `std::env::var`-shaped, faked in tests), `AWS_BEARER_TOKEN_BEDROCK` (same checks) then `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`(/`AWS_SESSION_TOKEN`); half a pair / NotUnicode / none → `Auth` naming the variable, before sending. `resolve_api_key_or_warn(Option<&ModelConfig>)` (Agent, SubAgentTool, LlmCompaction) keys on the **protocol**: any `BedrockConverseStream` config gets an empty key (never `API_KEY`/`YOAGENT_API_KEY`, whatever the provider string), so the provider reads env itself; `resolve_api_key("bedrock")` (public) prefers `AWS_BEARER_TOKEN_BEDROCK`. `request_headers` builds the whole `HeaderMap`: every value via `header_value` (credential values must be ASCII; failure → `Auth` naming the header, never the value; user non-auth header → `Other`), `authorization`/`x-amz-security-token` `set_sensitive`; on the SigV4 path a user header named like a signed one is refused; a `send()` `is_builder()` error → `Other`, not `Network`. SigV4 is the private `provider/sigv4.rs` (sha2 + hmac, no AWS SDK; pinned to aws-c-auth's `aws-sig-v4-test-suite` vectors incl. the `*-normalized` path ones): signing name `bedrock`, region from the host (`bedrock-runtime[-fips].<region>.<suffix>`, suffix ∈ `AWS_DNS_SUFFIXES` = partition dnsSuffix/dualStackDnsSuffix from botocore + VPC forms) > `AWS_REGION` > `AWS_DEFAULT_REGION` > `Auth` error; signs `host`, `content-type`, `x-amz-date`, `x-amz-content-sha256`, `x-amz-security-token` over the exact body bytes sent (serialize once — never `.json()`); `amz_date_now` errors on a pre-1970 clock; a 403 "Signature expired"/"not yet current" gets a check-the-clock hint. `base_url` is `trim_end_matches('/')`-ed; `canonical_uri` normalizes (drops empty/`.` segments, resolves `..`, keeps a trailing `/`) and encodes each segment again, so the model id (`:` → `%3A` on the wire, as the SDKs send it) is `%253A` in the canonical URI. `tests/bedrock_auth_test.rs` (env-mutating, serialized by a tokio `Mutex`) recomputes each signature from what wiremock received and uses `expect(0)` for every refusal. Never run against a live endpoint.

`ModelConfig` + quirk structs handle per-provider differences: `OpenAiCompat` (reasoning format, max_tokens field name, `max_reasoning_effort` ceiling — the ceiling, and `encrypted_reasoning`, are also read by Responses/Azure), `AnthropicCompat` (adaptive vs budget thinking, bearer auth, `native_structured_output`; `for_claude_id` infers them from an id), `GoogleCompat` (`thinkingLevel` vs `thinkingBudget`). `ThinkingLevel::Off` sends no thinking/effort field on any provider (DeepSeek's explicit `thinking: disabled` aside), so always-thinking models run at their default.

### Key Types

- **`Content`** — enum: `Text`, `Image`, `Thinking` (`thinking`, `signature`, `redacted` + `redacted_protocol: Option<ApiProtocol>` — provider-encrypted reasoning and the API that produced it (Anthropic `redacted_thinking` data, Bedrock `redactedContent`, or a Responses reasoning item as JSON beside its summary text); replayed only to that protocol (`Content::redacted_for`, crate-private), `None` = pre-field data, sent nowhere; `#[non_exhaustive]`, build with `Content::thinking*`, `thinking_redacted(protocol, data)`), `ToolCall`
- **`Message`** — enum: `User`, `Assistant`, `ToolResult` — each variant carries its own fields
- **`AgentMessage`** — `Llm(Message)` | `Custom(CustomMessage)` (renamed from `Extension(ExtensionMessage)` in 0.25; `ExtensionMessage` is a deprecated alias; untagged, so stored data is unchanged) — custom messages (`role` = `"extension"`, `kind`, `data`) don't enter LLM context
- **`AgentEvent`** — full event stream emitted to callers: `AgentStart`, `TurnStart`, `MessageStart/Update/End`, `ToolExecutionStart/Update/End`, `ProgressMessage`, `InputRejected`, `TurnEnd`, `AgentEnd`
- **`StopReason`** — `Stop`, `Length`, `ToolUse`, `Error`, `Aborted`, `Refusal`

### Context Management (`context.rs`)

- **`ContextTracker`** — hybrid real-usage + estimation; the loop uses it to calibrate the compaction budget against real provider usage
- **`compact_messages()`** — tiered compaction: Level 1 (truncate tool outputs) → Level 2 (summarize old turns) → Level 3 (drop middle turns)
- **`ExecutionLimits`/`ExecutionTracker`** — max turns (50), max tokens (1M), max duration (10 min)

### Tool Execution (`agent_loop.rs`)

`ToolExecutionStrategy` controls concurrency:
- `Parallel` (default) — `futures::join_all` for all tool calls
- `Sequential` — one at a time, checks steering queue between each
- `Batched { size }` — concurrent within batch, steering check between batches

**Structured outputs** (`Agent::prompt_structured::<T>(text, schema)`): the schema is threaded per-call through `prompt_messages_internal` into `StreamConfig.output_schema` (`OutputSchema` in `provider/traits.rs`) — never stored on the Agent. Errors: `Provider` (API failure, or a refusal / content-filter stop — carries its explanation), `Parse { source, raw }`, `NoOutput`; only this call's messages are scanned. Anthropic uses native JSON outputs (`output_config.format`, no tool, thinking kept, tool choice `auto`) when `AnthropicCompat::native_structured_output` is set — every `claude_*` preset, and `ModelConfig::anthropic` / OpenCode for Claude ids from 4.5 (inferred via `AnthropicCompat::for_claude_id`; `anthropic()` leaves a non-`claude-` id at `None`) set it — and otherwise falls back to tool-forcing (a synthetic tool + `tool_choice`; the loop's `unwrap_structured_tool_call` converts the forced call back to text **before** tool-call extraction, and runs only on the tool-forcing path — `structured_output_is_tool_forced` — so a user tool named like the schema still executes on the native path); OpenAI-compat uses `response_format: json_schema`; Gemini uses `responseSchema`. Responses/Azure/Vertex/Bedrock warn and ignore.

**Tool middleware** (`ToolMiddleware` in `types.rs`): async approve/deny/modify hooks gating every tool call, run in a chain at the single choke point (`execute_single_tool`) shared by all three strategies. `before_tool` takes a `#[non_exhaustive]` `ToolCallRequest` context struct (extensible without breaking implementors). `Deny(reason)` becomes an error tool result the LLM sees (loop continues); `Modify(args)` rewrites the call; a panicking middleware is contained as a denial. Installed via `Agent::with_tool_middleware` / `SubAgentTool::with_tool_middleware` / `AgentLoopConfig::tool_middleware`. Empty chain = allow all. `ToolCallRequest::new(id, name, &args)` + `with_messages` / `with_run_prompts` build one outside the loop (unit-testing middleware, or a `ToolGate` via `ToolGate::decide`). `user_request()` is built from `user_request_parts()` (`UserRequestParts { latest: String, reply: Option<ReplyContext { question, earlier_request }>, source: UserRequestSource::{Conversation, RunPrompts}, run_prompts: Vec<String> }`, all `#[non_exhaustive]`, also on `TurnContext`); the prose is documented as unstable — policies should read the parts. `SubAgentTool` mirrors `with_turn_hook` / `with_async_input_filter`; a sub-agent task rejected by a filter fails the tool call (detected via the `InputRejected` event, drained from the channel when there is no forwarder).

**Tool sources** (`ToolSource` in `tool_source.rs`, crate root re-export): one async `tools() -> Vec<Arc<dyn AgentTool>>`, installed via `Agent::with_tool_source` / `SubAgentTool::with_tool_source` (several allowed, installation order). Consulted **once per run** — every `prompt*` / `continue_loop*` (per delegation for a sub-agent) — in `collect_sourced_tools`, awaited **before** any agent state is touched (a dropped prompt future leaves the agent intact); never per turn (prefix cache, stranded calls). `take_tools(sourced)` appends them after the static tools (+ injected `shared_state`) via `tool_source::merge` and returns the static count; the run's list is `truncate`d back to it before `self.tools` is restored (inside the spawned task on the receiver paths), so sourced tools never outlive their run. Collisions: static wins, then earlier source, then earlier tool within a source; each drop `warn!`s, the run is never refused. The surviving sourced tools are then **sorted by name** (dedup first, so earlier-wins holds): tool definitions open the cached prefix, so a reordered-but-equal set must not rewrite it — per-run consultation only *bounds* cache rewrites to run boundaries, any change to the set still costs one. A panicking source is contained (contributes nothing, payload logged); the call is built inside the guarded `async` block (`(**source).tools()`), so a panic before the future exists is caught too. No timeout: `SubAgentTool` `select!`s collection against the parent's cancel token; on `Agent` dropping the prompt future is the escape (state untouched). Sourced `Arc`s reach the loop through the crate-private `tool_source::ArcTool` (also what `SubAgentTool` uses) — `AgentTool`/`AgentContext`/`AgentLoopConfig` (`new` + `#[non_exhaustive]`, #245) gained only the extension fields. Tests: `tests/tool_source_test.rs`.

**Extensions** (`extension.rs`, #241; guide `docs/concepts/extensions.md`): `Extension` (factory: `name`, `mode` Advisory/Required (`#[non_exhaustive]`), `filters_tool_output`, `rechecks_modified_calls`, `start_run(&RunContext) -> Box<dyn RunHooks>`; blanket `impl Extension for Arc<T>`) + `RunHooks` (per run, no-op defaults; `tools`, `on_input`, `before_model`, `on_stop`, `finish` take `&mut self` (write lock); `before_tool`, `after_tool(&self, call, &mut ToolOutput{result,is_error})`, sync `on_event(&self, event)` and `take_failure(&self) -> Option<String>` (polled in `sync_events`, i.e. at every `settle` and before tools, so a recorded failure fails a required run on any ending) take `&self` (read lock of a per-extension `tokio::sync::RwLock`), so parallel calls are judged concurrently). `ClonedHooks::new(name, hooks)` clones hooks per run (`.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`). `RunOutcome` = private `end() -> RunEnd { Completed, Stopped{reason}, Rejected{reason}, Cancelled, Failed{error, extension} }` + `stop_reason()` (built by `run_outcome` from the run's last message). Installed via `Agent`/`SubAgentTool::with_extension` (this agent) and `with_tree_extension` (every delegated run at any depth, prepended, tools not offered to children); config fields `extensions`, `tree_extensions`, `max_stop_continues` (default 3), `run_label` (`Agent::with_run_label`), crate-private `inherited_extensions` / `depth` set by the public `AgentLoopConfig::delegated_from(&ToolContext)` / `Agent::delegated_from` (what `SubAgentTool` and custom delegation tools call; reads `ToolContext::{tree_extensions, delegation_depth, run_label}`, carried in `ToolContext.delegation`). Dispatch is the crate-private `ActiveExtensions` (inherited, then own tree, then ordinary; it owns the run's id, label, prompts, depth and cancel token → `run_context()`), started in `start_extensions` **before** `AgentStart` (`name()`/`mode()` called inside `catch_unwind`). Every hook goes through `hook()` = `catch_unwind` raced (biased) against the run's cancel: cancelled `on_input` lets the run start (it ends `Cancelled` at the first check — a cancel is not a rejection), `before_tool` denies, `after_tool` withholds, `before_model` returns its notes, `on_stop` accepts; `finish` is bounded by `FINISH_TIMEOUT` (5 s). Points: `tools` merged via `tool_source::merge` in `run_with_extensions`, truncated off at run end; `on_input` after the input filters, prompt path only, with the text computed **before** the filters (no warnings); `before_model` once per turn inside `stream_assistant_response` before the retry loop, returning `Err(ModelHalt::{Stop,Fail})` (notes on the latest user turn, before `TurnHook` notes; `Stop` → `AGENT_STOPPED_PREFIX` marker + `close_turn`; `Fail` → `fail_run`); `sync_events()` before a response's tools run; `before_tool` after the `ToolMiddleware` chain with a recheck of `rechecks_modified_calls` extensions before the last modifier; `after_tool` before `ToolExecutionEnd`/truncation/sink (only for a tool that exists); `filters_tool_output` → no `on_update`/`on_progress`; `on_stop` (`check_stop`) after follow-ups when the last message is an assistant `Stop` — every `Continue` is sent as one `EXTENSION_MESSAGE_PREFIX` ("[Extension message: <name>] …", in `is_loop_injected`) line, capped; at the cap a required extension that continued fails the run; `on_event` via `EventObserver` (a forwarder task created only when extensions exist; flush = oneshot ack after draining; an `on_event` that panics is switched off for the run). Failures: a required failure → `fail_run` (latches the run so it fails once) → assistant `Error` with `EXTENSION_FAILED_PREFIX`, `on_error`, inside a turn (its own `TurnStart`/`TurnEnd` when none is open). Failures recorded off the decision path (`tools`, `after_tool`, `on_event`) are acted on by `settle()` (flush + `take_failure`) at the top of each turn before the limit/`on_before_turn` checks, before `check_stop`, and after `run_loop` returns on any path; meanwhile `has_failure()` makes `execute_single_tool` answer calls not yet started ("a required extension failed the run"). `start` keeps the started extensions when another fails (they still get `finish`); a filtering extension that cannot start fails the run whatever its mode. A run cancelled before its first request closes the first turn; a `SubAgentTool` child cancelled before any assistant message is `ToolError::Cancelled` (not an empty success). `extension::Budget` (dollar limit checked in `before_model` → `Stop`; spend = assistant `MessageEnd` usage priced with one `CostConfig`, plus the run's own decision and compaction spend (a crate-private `RunSpend` ledger in `RunContext.spend`, fed by `record_decision` / `record_compaction`, read in `before_model` and `finish`; unpriced pieces at the budget's rate), plus a sub-agent's `ToolExecutionEnd` `SUB_AGENT_STATS_KEY` stats unless this budget served that very child run as an inherited tree extension (a `children` set keyed `parent_run_id/call_id`, from `RunContext::{parent_run_id, delegated_by, inherited}`, carried in `Delegation.{parent_run_id, call_id}`); per run (spend in the run's hooks), or `.across_runs()` shared total readable via `spent_usd()` through an `Arc<Budget>`; `for_model` → `None` when unpriced; negative/NaN limit panics; `with_name`). The decision features run as extensions (`decision::wire` returns the advisor and gate, appended after the agent's own; `with_input_guard` = `with_extension`). The older hooks are unchanged; input filters run before `on_input`, middleware before `before_tool`. Tests: `tests/extension_test.rs`, `tests/extension_budget_test.rs`, the wasm32 smoke test in `tests/wasm32.rs`; `tests/hook_order_test.rs` pins that the older hooks are unaffected.

### Workers bindings (`integrations/yoagent-workers/`, separate crate)

Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoagent extension points. wasm32-only: every dependency is under `[target.'cfg(target_arch = "wasm32")']`, `lib.rs` gates its modules, and on other targets the crate is empty. Not a workspace member; `yoagent` by path + version with `default-features = false, features = ["decision"]`; Published on crates.io: 0.1.0 (2026-10-04) for yoagent 0.24 (`decision::parse_systemone_response`), 0.2.0 for yoagent 0.25 (`prices::global::pricing_enabled` / opt-in pricing); keep its `yoagent` requirement at the path's version when releasing. No `worker` crate dependency: bindings are taken as `impl Into<JsValue>` (workers-rs's `Ai` converts). `ai`: `AiBackend` (`DecisionBackend`, `async_trait(?Send)`) serializes the `Request` to a JS object, calls `env.AI.run(model_path, input)` (a sync throw or a rejection is read for a leading `NNNN:` Workers AI code, after an optional `Name: `: 3040 → `RateLimited`, retried with `with_retry`'s `RetryConfig` via `yoagent::rt::sleep`; 3036 → `Http` 429; else `Backend` with the JS name + message; no `run` → `Invalid`; non-JSON output → `BadResponse`), then `parse_systemone_response`; `clef(ai)` / `clef_flash(ai)` = `from_backend(..).with_cost(prices cloudflare/<id>)` resolved at construction (unpriced unless the Worker called `prices::enable_bundled()` first; warns only when prices are enabled but the id is missing). A binding belongs to one request: models and agents are built inside the handler. Tests: `tests/node.rs` (wasm-bindgen-test under Node, fake bindings built with `Function::new_with_args`). Example Worker `examples/clef-worker/` (own `[workspace]`, path deps, `cdylib`; not auto-discovered by the crate and not packaged): DeepSeek agent + two note tools (one note carries an injected instruction) + `ToolGate` over Clef (`AiBackend` wrapped in a `Timed` `DecisionBackend` that records each call's ms), secrets `DEEPSEEK_API_KEY` / `RUN_TOKEN` (empty = 500); response classifies tool outcomes (`ok`/`denied`/`gate_unavailable`/`failed`, by the `Tool call denied:` and gate-unavailable text prefixes) and reads the run's end from `AgentEnd` (502 on Error/Aborted, 422 on Refusal, `stopped` on `AGENT_STOPPED_PREFIX`). Managed with the Cloudflare CLI `cf` (beta): `cloudflare.config.ts` (entrypoint `build/index.js`, AI binding `dev.remote`, `bindings.secret()`s) + `wrangler.config.ts` (the `worker-build@^0.8` build command; cf delegates Rust builds to Wrangler ≥ 4.136 from `package.json`); no `wrangler.toml`. CI type-checks and lints it (fmt, wasm32 clippy) but never builds it with worker-build or runs it. Run live once (2026-10-04, `cf dev`, DeepSeek + real `env.AI`): Clef allowed `list_notes` and a requested `delete_note`; the bad-key path gave 502; no live denial observed (DeepSeek ignored the injected note). `cf build` / `cf deploy` (beta.12) write `.cloudflare/output/v0/` but do not exit — deploy with `cf deploy --prebuilt --secrets-file <file>`, delete with `cf workers delete <name> --force`; `cf dev` works. `?gate=jev` swaps the gate to Jev (`SystemOneBackend::typesafe().with_api_key(TYPESAFE_API_KEY)`), timed by the same `Timed` wrapper (`timing: {gate, total_ms, gate_ms}`). Edge test 2026-10-04 (deployed, then deleted): Clef median 308 ms, Jev 230 ms per gate check. A `.dev.vars` for local runs is git-ignored — delete it after. In a Worker prefer `Agent::from_provider` (one provider, no decision model: ~328 KiB gzipped) over `from_config` (all seven, ~470 KiB). CI job `workers-bindings` (fmt, wasm32 clippy with an absolute `CLIPPY_CONF_DIR` — a relative one resolves against the nested package —, wasm32 docs, native check + docs of the empty crate, Node tests). `rust-version` mirrors yoagent's but is not CI-checked. Locally: a rustup toolchain with the wasm32 target first on PATH and a `wasm-bindgen-test-runner` matching the resolved `wasm-bindgen`; run with `--manifest-path integrations/yoagent-workers/Cargo.toml --target wasm32-unknown-unknown`.

### rutis bridge (`integrations/yoagent-rutis/`, separate crate)

Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.6"` (0.x caret = 0.6.x; any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option<Box<dyn EventSink>>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:<name>` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Python plugins** (`languages.rs`, features `node`/`python`/`websocket` → optional `rutis-bridge = "0.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (websocket frame limit 16 MiB), mimeType in `IMAGE_TYPES` png/jpeg/gif/webp; nothing else; in `after_tool` an image identical to one in the given output (`kept`) passes unchecked) — which `after_tool`'s edit also takes (`content` replaces every block; `after_tool` sees `output.content`); an `is_error` result's message is its text blocks joined, `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7 allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.7.0, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1; separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:<call_id>", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, text blocks kept, an image block (`{attachment: ImageAttachmentRef}`) read with the `attachments` service (`readImage(ref, signal)` → base64 + `ref.mediaType`) looked up **per image** via `ctx.use` in a try (never injected — the adapter runs without a store; none or a failed read → `[image …: not available here]` text; dsh's `offloaded` → `[…: offloaded]`; over `MAX_IMAGE_BYTES` 3.75 MB (ref `bytes` or read size) → `[…: too large to send]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY` / `~/.dskey`; self-checks). **pi adapter** (`plugins/pi/`, own `package.json` + lock pinning `@earendil-works/pi-coding-agent`/`pi-ai`/`pi-tui` 1.1.0, `typebox`, `jiti`, rutis 0.7.0; `plugins/pi/node_modules` excluded from the package): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files or dirs with `index.ts`, relative to `cwd`), `cwd`, `name` (default `pi-extensions`), `toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`) loads pi extensions with pi's own `loadExtensions`, imported **by file** (`dist/core/extensions/loader.js` resolved from the package: the export map has only `discoverAndLoadExtensions`, which also loads `<cwd>/.pi/extensions` and `~/.pi/agent/extensions`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop. **Fail closed by default:** loader errors, a throwing `session_start` handler, an unfired `DECIDING_EVENTS` handler (`context`, `context_with_system`, `message_end`, `before_provider_request`, `before_provider_headers`; unless in `allowUnmapped`) and a pi tool named like a `TOOL_NAMES`/`toolNames` key not in `withoutBuiltins` (`checkShadowing`) refuse the load; registered after load (`checkUnmapped(false)`/`checkShadowing(false)` from `tools()`, `before_model` and `before_tool`, so a mid-run registration stops that run) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. Other unfired events, commands/shortcuts/flags/renderers/providers/virtual models/MCP servers → one `console.warn` each (`reported` set; load error with `strict`). The `session_shutdown` effect is registered before the refusals. Runtime: `setActiveTools` → `active` allowlist over the names pi knows (`inactive()`: registered pi tools + `names` values; MCP/sub-agent/host tools unaffected); as pi's `_applyToolLoadout`, a listed tool is available unless `hidden` (`defaultActive` ignored), and one registered after it that would activate on registration joins it (`knownAtActivation`); `getActiveTools` (refreshes first, so a tool registered since joins — pi refreshes on `registerTool`; without an allowlist it omits `withoutBuiltins`)/`getAllTools` from it (built-ins as `builtin:<name>` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `appendEntry` → `sessionManager.appendCustomEntry`, `sendMessage` → `sessionManager.appendCustomMessageEntry` (a `custom_message` entry, as pi's runtime stores it; `display: true` ones also `report`ed at info; `triggerTurn`/`deliverAs` warned, never a turn) — so pi tools that record results this way don't fail; `UNSUPPORTED_ACTIONS` (`sendUserMessage`, `setSessionName`, ...) and `setModel` replaced with "not available in yoagent" throws. Tools = `registered()` (first registration of a name wins) filtered by `available()` (exposure `direct`/`model-only`, `defaultActive !== false`, in `active`); read per run. `before_tool`: `refusal` → deny; a built-in whose pi counterpart an available pi tool has → deny ("call X instead"); a judged name outside `active` → deny; for a pi tool `prepareArguments` **on a `structuredClone`** then pi-ai's `validateToolArguments` (throw → deny) before the policies, `judged` (`run_id/call_id` → `canon` JSON) recorded, `{args}` when it differs from the raw call; for a built-in (`YOAGENT_KEYS` tool only — never an MCP/host tool's `path`) a relative `path` is resolved against `cwd` (when there are policies), then `ARGS` — keyed by the **yoagent** name — maps `edit_file` (one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)`), `list_files` (`pattern` default `*`), and a rewritten field outside `YOAGENT_KEYS` denies. `{block}`/a throw → deny; `terminate: true` → `terminated` → the run's next `before_model` stops (stricter than pi's all-results batch rule). `tools()` returns nothing once `refusal` is set. `call_tool` runs a pi tool only if `canon(call.args)` equals `judged` (a later handler's rewrite → error result, not run). `canon` = key-sorted JSON with non-integers at 15 significant digits (args cross serde_json, which may reorder keys and re-parse a float one ULP off). `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, returns only the fields set (`text` only when content was replaced); a throwing handler → throw (the bridge withholds) — unlike pi. `on_input`: `handled`/`transform`/throw → reject. `before_agent_start` → `before_model`, handlers once per run (memoized by `run_id`, cleared in `finish` with `terminated`/`judged`), the note on every request; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder; per handler try/catch (a throw, a non-string or placeholder-less `systemPrompt`, a `systemPromptOptions` write) → skipped with a warning; a `message` dropped, its `systemPrompt` kept. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `onTerminalInput` → unsubscribe fn, `then` undefined, `theme` = identity Proxy), `hasUI` false, `isProjectTrusted` false, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact` throw; `ctx.executeTool(name, args, {signal})` = `executeNested`: pi tools only (`callable()`: `direct` while available, `codemode`, `deferred`), prepare + validate, `tool_call` handlers with `parentToolCallId`, `execute` (ctx with its own nested `executeTool`, depth ≤ 8), `tool_result` handlers (a tool's throw is an error result that still goes through them, as in pi; a handler's throw → error outcome, withheld), ids `<parent>/<n>`, missing args → `{}`, refused once `refusal` is set (checks re-run per nested call), depth < 8, returns `{toolCall, result, isError, durationMs}` (error `details` `{}`), never rejects; `ctx.tools` = `callable()`. Diagnostics go through a module-level `report` → `yoagent.log` when the host has it, else `console.warn` (also `ctx.ui.notify`). Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites and a two-edit rewrite on `multi.txt`, `SECRET` redaction, a prompt addition, `session_shutdown` writes `shutdown.txt`, a command) and `fixture-extra.ts` (duplicate `pi_echo`, `defaultActive:false`, `isError`, an `edit` override whose `prepareArguments` mutates in place, a throwing policy, grep glob/ignoreCase and find path rewrites, `timeout` and `terminate` markers, a details-only and a throwing result handler, four `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, 9 tests (+ a mid-run deciding event), and `tests/plugin_log_test.rs` (own binary, global `tracing` subscriber: the adapter's warning arrives at target `yoagent_rutis::plugin`); the weekly workflow `.github/workflows/pi-latest.yml` runs `pi_test` against `@latest` pi packages installed `--no-save` over the pinned ones (red = check the adapter before bumping the pin); the original 8: end to end, less common paths, strict, same-name refusal + `withoutBuiltins`, load refusals + `allowUnmapped`, `setActiveTools`, per-run notes + input rejection, a later handler's rewrite not run; temp extensions via `extension()`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks (the write denied as protected, bash ran); chdirs into the temp project; `--without NAME` drops a yoagent built-in and passes it as `withoutBuiltins`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (pi 1.1.0, Oct 2026, scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.7.0 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`; an output that parses wholly as `{"content": [text/image blocks]}` with at least one image → those yoagent blocks — the adapter's image convention, rutis-agent itself has text results only — shown by `dot_picture`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.7.0), `requirements.txt` (`rutis==0.7.0`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 24 / `plugins/node_modules` / Python with rutis 0.7 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.86, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 24, Python 3.12: `npm ci` in `plugins/`, `plugins/dsh/` and `plugins/pi/` (setup-node `cache: npm` keyed on the three lockfiles), clippy per feature, docs, tests with the runtimes required, the `language_plugins` and (scripted, fixture) `pi_extensions` examples; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`.

### OpenAPI Integration (`openapi/`, feature-gated)

Behind the `openapi` Cargo feature. `OpenApiToolAdapter` parses an OpenAPI 3.0 spec and creates one `AgentTool` per operation. Factory methods: `from_str`, `from_file`, `from_url`, `from_spec`. `OperationFilter` controls which operations become tools. Added to `Agent` via `with_openapi_file()` / `with_openapi_url()` / `with_openapi_spec()`.

### MCP Integration (`mcp/`)

`McpClient` communicates via `McpTransport` trait (stdio or HTTP; `notify` sends a `JsonRpcNotification`; the trait default = old best-effort request-and-wait (error logged, `Ok`) for custom transports; a built-in transport's failed `initialized` fails the connect; `connect_*` bound the handshake at 60 s). Stdio: reads until the response with the request's id (numeric or string form), skips server notifications, answers server requests (`ping` → `{}`, others → -32601, `warn!`), drops stale ids, skips non-JSON lines with `warn!` (counted into the close error); a null-id error is taken as the current call's, with `warn!`; `StdinWriter.interrupted` starts the next message on a fresh line after a cut-off write; stderr drained lossily in 8 KiB chunks to `debug!` (target `yoagent::mcp::stderr`) with a 4 KiB `StderrTail`; EOF → `McpError::Transport("Connection closed: … exited with …; last stderr: …")` (waits ≤500 ms for the drain); `kill_on_drop`; tested against bash-scripted servers in `tests/mcp_stdio_test.rs` (non-UTF-8 stderr flood, late answer, kill on drop, death with stderr). Call timeout lives on `McpClient` (`call_timeout`: 300 s stdio, None HTTP/custom) and is copied by `McpToolAdapter::from_client*`; `McpToolAdapter::new` has none; the clock starts after the client lock is held (only cancel ends the wait). `McpToolCallResult.content` is lenient: an unmodelled block becomes `McpContent::Text` (a text resource's own text, else JSON with `data`/`blob` over 1 KiB replaced by their size; `warn!` once per type). `McpToolAdapter` wraps MCP tools to implement `AgentTool`, making them transparent to the agent loop. Added via `Agent::with_mcp_server_stdio()` / `with_mcp_server_http()`.

### GASP Bridge (`gasp.rs`, feature-gated)

Behind the `gasp` Cargo feature (dep: `yoagent-state`, the GASP reference runtime). `GaspRecorder` consumes the `AgentEvent` stream (via `recording_sender`, which also tees to a forward sender) and maps it onto `yoagent_state`'s `YoAgentStateSink`: AgentStart→run.started, assistant MessageEnd→model.called/finished pair, ToolExecutionStart/End→tool.called/finished, AgentEnd→run.finished + one git commit per run at stream close (scaffolding committed at init so clones restore). Stale open runs are closed as "interrupted" on open; a dropped sender finishes the run with the derived outcome; InputRejected → "rejected". Recording failures stop recording but the forward tee keeps flowing (error surfaces only via the returned handle — await it); events are forwarded BEFORE recording. `Ok(None)` when no AgentStart arrived. Zero loop changes. **Plugin logs (opt-in):** `recorder.extension()` (`GaspRunLink`, an `Extension` whose `start_run` adds `run.run_id` to the open recording's `OpenRoute.runs`; no hooks) + `recorder.plugin_log_layer()` (`PluginLogLayer`, a `tracing_subscriber::Layer` — the `gasp` feature enables optional `tracing-subscriber` with `registry` only — that forwards events of target `PLUGIN_LOG_TARGET` = `yoagent_rutis::plugin` whose `run_id` field is in `runs` over an unbounded channel); `recording_sender` opens the route, `consume` `select!`s events and log lines, buffers lines until `AgentStart`, writes each as `record_observation` (`observed_in` the GASP run, title `plugin <level>`, summary through the summarizer, metadata `{source: "plugin", level}`), records lines already queued before recording `AgentEnd` (so they stay inside the run), and at stream end clears the route **only if it is still its own** (`OpenRoute.tx.same_channel(&Logs.own)`; a next `recording_sender` may already have replaced it) and drains queued lines. Lines with another or no `run_id` are never recorded. Test: `tests/gasp_plugin_log_test.rs` (own binary, global subscriber). CI job `gasp-conformance` emits a repo via `examples/gasp_emit.rs` and runs the gasp conformance checker (7 checks) against it.

### Decision Models (`decision/`, feature-gated)

Behind the `decision` Cargo feature (no deps; off by default; nothing is sent until a `DecisionModel` is constructed and used — an env key alone enables nothing, proven by `tests/decision_env_test.rs` against a wiremock `expect(0)`). Typed `Question`s (`Noul`/`Choice`/`Score`; "Noul" is SystemOne wire vocabulary) about a JSON/text `state` → `Evaluation` (private fields; getters `model()`, `usage()`, `cost_usd()`, `answers()` in request order; `new`/`with_answer`/`with_cost_usd` for backends); answers have private fields + getters (`NoulAnswer::p_true()`, `ChoiceAnswer` in option order, `ScoreAnswer`). `DecisionModel` (cheap clone) wraps a `DecisionBackend` trait (`capabilities()`, `evaluate(&Request)`): `SystemOneBackend` (HTTP `POST /v1/systemone`, or an exact URL via `with_endpoint_url`; its parser is public as `parse_systemone_response` for backends that move the request themselves; Cloudflare's `{"result": ...}` envelope unwrapped (a text `result` is parsed, a non-object one is `BadResponse`); a 2xx `"success": false` → `Invalid` for schema code 5006, else `Backend`; a 429 with Cloudflare code 3036 (daily allocation) → non-retryable `Http`, other 429/529 → `RateLimited` carrying the body; env keys trimmed, 401/403 name the variable; lenient parsing; 429/529 retried via `RetryConfig`, `retry-after` wins) and `MockBackend`. Presets `jev()` (`TYPESAFE_API_KEY`/`TYPESAFE_BASE_URL`, read at call time), `jev_opencode()`, `jev_opencode_free()` (`OPENCODE_API_KEY`), `clef(account_id)` / `clef_flash(account_id)` (Cloudflare Workers AI via `SystemOneBackend::workers_ai(account, "@cf/cloudflare/clef")`; token `CLOUDFLARE_API_TOKEN` then `CLOUDFLARE_AUTH_TOKEN`, read at call time), `local(url)` (sets `Capabilities::local` = self-hosted), `logprobs(url, model)` (`LogprobBackend`, see below), `gpt_6_luna()` / `from_openai_backend(backend, id)` (`OpenAiDecisionBackend` in `decision/openai.rs`, own `Slot::OpenAi` so `with_api_key`/`with_retry` apply: OpenAI's Decisions API `POST {base}/decisions`, base `https://api.openai.com/v1` (`/v1` included), `OPENAI_API_KEY` read at call time + trimmed, 401/403 name it; Noul→`predicate`, Choice→`choice` (string `value`s, criteria→`description`), Score→`score` (`levels[].label`, answer probabilities by level index `value`), id→`name`, JSON state/instructions → pretty JSON text, Noul criteria appended to instructions; answers matched by `name`, unnamed ones by position only when counts match; `refusal` → `DecisionError::Refused` naming the question (not retried, terminal in an `or` chain; gate/guard fail closed; its usage still recorded); no predicate confidence → computed; capabilities = `Capabilities::systemone()` (no published limits); `with_safety_identifier`; images unsupported; HTTP via `systemone::{post_json, with_retries}`; mock-only, never run live — `tests/decision_openai_test.rs`), `from_backend` / `from_arc`. Requests validated against `Capabilities` (2–255 options, 2–10 levels, 64k/32k token estimates); **answers validated centrally in `check_complete` for every backend** (kind, finite probs/confidence in [0,1], a probability for every Choice option / Score level, sums within `max(0.02, n × 0.005)` of 1 for n options/levels (TypeSafe rounds to 2 dp), choice ∈ options, score in range) → `BadResponse`; answers nobody asked for are dropped and the rest reordered to request order (so a non-batching merge cannot be overwritten). `DecisionError` is `Clone` without `PartialEq`; `Backend`/`Transport` are struct variants with an optional `Arc<dyn Error>` source. Missing confidence = `(n*p_max-1)/(n-1)`. Pricing (all unpriced until the process opts in — `prices::enable_bundled()` etc.): `jev()` uses `typesafe/<reported id>` from the process-wide table only when the base URL is TypeSafe's host; `clef*()` use `cloudflare/<model id>` (an `@cf/cloudflare/` prefix stripped) only while the host is `api.cloudflare.com`; `gpt_6_luna()` uses `openai-decisions/<reported id>` (its own key, not chat `openai/gpt-6-luna`; $0.10/M input, output free) only while the host is `api.openai.com`, an unlisted id warns once — only while pricing is enabled — via the per-key `decision::warn_once` (also used by the no-usage warning and the two `deprecated:` keys: gate as middleware, guard as filter; once per process); gateways and `from_backend` unpriced; `local()` $0; an unpriced handle keeps a backend-reported cost; a response without usage is unpriced under a non-zero price, while a free handle (`local()`, all-zero rates) stays $0.

Agent integration (`decision::wire` returns extensions, appended in `Agent::build_config` / `SubAgentTool`): `with_decision_model` → advisory only (crate-private `Advisor`, installed as the `AdvisorExtension` extension's `before_model` note: one batched request per user request, memoized; skill hint via Choice+Noul at need ≥0.3 / confidence ≥0.5, tool hint at ≥40 tools; 2 s; failures warn and add nothing; does nothing without skills and with <40 tools). `with_tool_gate(ToolGate)` → installed as an extension (`impl Extension for ToolGate`, `rechecks_modified_calls` = true; its `ToolMiddleware` impl is deprecated (warns once), `ToolGate::decide` drives it directly) appended after the agent's own extensions, so it runs last; Nouls destructive/requested, deny when destructive ≥0.5 and requested <0.7; state `{user_request, tool_call}` with long strings head+tail-truncated and >12k chars denied; every comparison denies on NaN/missing; threshold setters and check-id collisions panic; **fails closed**. Not a security boundary. Spend: every `evaluate_request` inside a loop records into the `tokio::task_local!` `LoopScope` (`agent_loop::record_decision`), opened in `agent_loop_with_stats` **before the input filters** (a rejected run's `AgentEnd` carries the spend) and in `agent_loop_continue_with_stats` → `SessionStats::decision` (`DecisionStats`, with `unpriced` counting evaluations of unknown cost), child runs folded in (not into `sub_agents`); counted in `total_cost_usd`; a non-batching request that fails or times out partway records the usage already billed (accumulated outside the timed-out future).

Phase 3–4 (`decision/logprobs.rs`, `calibrate.rs`, `guard.rs`; fallback and the split path in `mod.rs`). **`LogprobBackend`** (`DecisionModel::logprobs(url, model)`, or `from_logprob_backend(backend, model)` to keep the logprobs slot — `with_api_key`/`with_retry`/loopback $0 — with custom settings; there is no `DecisionModel::with_temperature`): any OpenAI-compatible `/chat/completions` with logprobs; one request per question (`max_tokens:1`, `temperature:0`, `logprobs:true`, `top_logprobs:max(K=20, labels)`, `extra_body` deep-merged (nested objects key by key) and inserted under the core fields; `with_thinking_disabled` = `chat_template_kwargs.enable_thinking=false`, not default because OpenAI rejects unknown fields), single user message; labels A/B (Noul, A = yes), A.. (Choice, ≤26), 0–9 (Score); `KINDS` lists the three kinds explicitly and every match is exhaustive (a future kind → `Unsupported`). `label_distribution`: trim + upper-case, sum duplicates; no label or label mass < `min_label_mass` (0.5) → `BadResponse` (a `<think>` first token or a word answer is noise, not a confident answer — the gate/guard then fail closed); an absent label gets `min(smallest reported p, 1 − Σreported)` clamped to `[1e-9, 1]`; temperature on ln(mass), softmax, then an absent label is re-floored at 1e-9 after scaling (a tiny temperature would underflow it), so absence never gives 0/1. Reports `batching:false` + `Capabilities::max_concurrent_requests = 8` (new field, default 1): `DecisionModel::evaluate_singles` splits the request into boxed futures built in a loop (a closure trips the `Send` HRTB check), runs them via `FuturesUnordered` up to the limit, stops starting new ones after the first failure but awaits those in flight, adds each completed one to `billed` (so errors and timeouts record the spend of completed questions), returns the earliest failed question's error. `local` only for a loopback host (`is_loopback_url`: localhost/`*.localhost`/127/8/::1; `0.0.0.0` is *not*, which the unix-only remote-pricing wiremock test relies on); loopback → `Pricing::Fixed(0)`, else unpriced. HTTP shared with SystemOne via `systemone::{post_json, with_retries, status_error}` (429/529/transport retried; other 5xx is `Http`; a reqwest `is_builder()` error — malformed URL — is `Invalid`). **Fallback** `DecisionModel::or`: `fallbacks: Vec<DecisionModel>` (flattened; each member sends its own model id), `evaluate_chain` — structural validation against `Capabilities::unlimited()` first (→ `Invalid`, no member tried), then per member: capability-invalid → skipped unsent (`FallbackAttempt { was_sent: false }`, not recorded); any member error, `Invalid` included, falls through — except `Refused`, which is returned at once; one `rt::Instant` deadline from the head's `timeout` (members' `timeout` ignored) judged by the clock after each failure (a backend's own `Timeout` does not end the chain), per-attempt limit `min(remaining, attempt_timeout)`; deadline passed → `Timeout(head timeout)`; exhaustion → `DecisionError::AllFailed { attempts: Vec<FallbackAttempt> }` (getters `model`/`error`/`was_sent`; `is_retryable` if any member's error was). Each sent attempt is recorded (`record_attempt`); nothing sent → one failure. `model()`/`capabilities()`/builders are the primary's. **`calibrate` / `calibrate_with`** (`CalibrationOptions` private fields + getters): one request per example, `buffer_unordered(concurrency)`; top-label ECE (10 bins), Brier binary for Noul / multi-class otherwise, Noul F1 and target-precision thresholds over observed `p_true`, temperature by NLL grid `1.05^k, k∈[-33,33]` (ties → closest to 1) where only p>0 entries are rescaled and only the true outcome's rescaled p is floored; `accuracy`/`brier`/`ece` are `Option` (None when nothing evaluated); `models` counts per `Evaluation::model` + `mixed_models()`; `errors: Vec<CalibrationError>`. **`InputGuard`** (`Agent`/`SubAgentTool::with_input_guard` = `assert_has_checks()` (panics on no checks) + `with_extension` (`impl Extension for InputGuard`, `on_input`; its `AsyncInputFilter` impl is deprecated (warns once), `InputGuard::screen` drives it directly)): built-ins `injection` / `harmful` at 0.8, stored as a `defaults` flag + `custom` list + `thresholds` overrides, resolved lazily in `checks()` (a custom check with a built-in id replaces it in place and survives `without_default_checks`, whatever the call order); state `{input}` with the **whole** text (no truncation); over `max_input_chars` (32k) → not sent, failure policy; 3 s, fail-closed (`with_fail_open` opt-out); empty text → pass; no checks used directly → Reject; steering/follow-ups are not filtered (loop unchanged). Tests: `decision_logprobs_test` (wiremock, incl. split-request spend via an input guard), `decision_fallback_test`, `decision_calibrate_test`, `decision_guard_test` (MockBackend).

The generic hooks it rides on need no feature: `ToolCallRequest::messages` / `run_prompts` + `latest_user_text()` / `user_request()` (shared helper `types::user_request_of`: never looks back past a compaction boundary (`COMPACTION_MARKER`, `SUMMARY_MARKER`, `[Summary]`) nor past the latest real user message even when it has no text (image-only) — that message's text, else the run's prompts (kept in the loop's task-local `LoopScope`, fed from the run's prompts and injected steering/follow-ups, so compaction cannot remove them), else `None` → the gate denies; skips loop-injected user messages via the public predicate `is_loop_injected` (crate root), built from `context::{SUMMARY_PREFIX, COMPACTION_MARKER}` and `agent_loop::LOOP_NUDGE_PREFIX` (crate-private) plus the public `llm_compaction::SUMMARY_MARKER`, `agent_loop::{AGENT_STOPPED_PREFIX, LOOP_ABORT_PREFIX}`, `extension::EXTENSION_MESSAGE_PREFIX`; includes the previous assistant text + earlier request only when the reply is <40 chars AND the assistant text ends with a question outside code), `AsyncInputFilter` (provided `InputFilter::as_async` + `AsyncFilter::new`; panics contained → Reject), and `TurnHook` applied by wrapping the provider in `TurnHookProvider`, which appends notes to the outgoing request's **latest user turn** (system prompt and history prefix untouched; never stored). `AgentLoopConfig` (`new` + `#[non_exhaustive]`, #245) gained only the extension fields.

### Session Trees (`session.rs`)

`Session` stores history as an id/parent_id tree: `append` advances the head, `seek`/`seek_checkpoint` move it, appending after a seek forks a new branch (never overwrites). `path_messages()` feeds a branch into `Agent::with_messages`; `append_new(agent.messages())` is the post-run sync. JSONL persistence (`to_jsonl`/`from_jsonl`, head = last line). Freestanding — no loop changes; maps to GASP's `transcripts/` tier.

### Shared State (`shared_state.rs`)

`SharedState` is a pluggable key-value store (`Arc<dyn SharedStateBackend>`) for sub-agent communication. It lets a parent store large artifacts once and have multiple sub-agents read/write by reference — no re-pasting into prompts.

- Two built-in backends: `MemoryBackend` (default, `HashMap` with 10MB cap) and `FileBackend` (one file per key, persistent)
- Custom backends implement the `SharedStateBackend` trait
- Opt-in via `SubAgentTool::with_shared_state(state)` — injects a `shared_state` tool and appends a state summary to the sub-agent's system prompt automatically
- Actions: `get`, `set`, `list`, `remove`
- Opt in on a parent via `Agent::with_shared_state(state)`, which also registers
  the `shared_state` tool for that run
- The loop reads it at one point only: `AgentLoopConfig::tool_output_sink`, used
  on the **append path** to stash the full text of a truncated tool result so the
  marker can name a retrievable key. Never on the compaction path — by then the
  middle is already gone, and re-deriving there would move marker bytes that the
  prefix cache depends on

### Construction API

The primary constructor is `Agent::from_config(ModelConfig)`: it selects the built-in provider from `config.api`, sets the model id from `config.id`, and resolves the API key from the provider-conventional env var (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY`, …; see `provider::resolve_api_key`). This removes the provider↔config pairing footgun and the doubly-specified model id.

```rust
// provider auto-selected, key from XAI_API_KEY
let agent = Agent::from_config(ModelConfig::xai("grok-4.7", "Grok 4.7"));
```

Other constructors:
- `Agent::from_provider(provider, config)` — explicit provider (custom impls, test doubles). Pair with `ModelConfig::mock()` in tests.
- `Agent::from_config_with(&registry, config) -> Result<_, AgentBuildError>` — resolve against a custom `ProviderRegistry`.
- `Agent::set_model(config)` — switch model mid-session (re-resolves the env key; re-selects the provider only when it was registry-resolved, never clobbering an explicit one; explicit keys preserved).
- `Agent::new(provider)` + `with_model`/`with_model_config` — the original builder, deprecated since 0.10 and `#[doc(hidden)]` since 0.25 (same on `SubAgentTool`); removal promised for 1.0.

`SubAgentTool` mirrors these: `from_config`, `from_config_with`, `from_provider`. Its limits are a stored `ExecutionLimits` (default 10 turns / 1M tokens / 300 s, loop detection on): `with_execution_limits` replaces them, `with_max_turns` changes only the turns.

**Sub-agent spend** is a separate bucket, never merged into the parent's own
figures: `SessionStats::sub_agents` (`SubAgentSpend { usage, cost_usd, runs }`),
summed over the whole delegation tree, each run priced at its own model's rates;
`total_usage()`/`total_cost_usd()` add them (and the compaction bucket; `total_cost_usd()` also decision spend — see below). The child's `SessionStats`
reach the loop through `ToolContext::report_delegated_run` (public, so custom
delegation tools report too; must be called before `execute` returns) into a
private side channel — so a failed delegation, which returns `Err`, still
reports — and are folded in at `execute_single_tool`, which also attaches them
(combined, when one call reported several runs) to the `ToolExecutionEnd`
details (`SessionStats::from_sub_agent_result`). Every cost rollup —
`record_turn`, `SubAgentSpend::merge`, `total_cost_usd` — goes through one
`combine_cost` rule: non-zero usage with no cost is *unpriced* and sticky-`None`;
`None` with zero usage is "nothing spent" (`is_unpriced()` tells them apart).
`Agent` accumulates each run's `SessionStats`: `sub_agent_spend()`,
`total_cost_usd()` and `total_usage()` share that runs-since-construction-or-
`reset()` window, unaffected by clearing/replacing history, whereas
`session_cost_usd()` is history-derived and excludes sub-agents.

**Compaction spend** (`LlmCompaction`'s summaries, #228) is a third bucket:
`SessionStats::compaction` (`CompactionSpend { usage, cost_usd, requests }`,
priced at the summarizer's own `cost`), in `total_usage()`/`total_cost_usd()`,
`Agent::compaction_spend()`. The request runs on a spawned task (outside the
`LoopScope`), so it records into a ledger in `State.unreported` (every
response, accepted or rejected; errors/timeouts have no usage); `compact()`
starts with `report_spend`, which moves the ledger into the scope via
`agent_loop::record_compaction` — only inside a loop (outside one it stays in
the ledger). So a request in flight at run end is counted by the next run.
`with_loop_scope` returns `ScopeSpend { decision, compaction }`; child runs fold
into `compaction` (not `sub_agents`), like `decision`.

`AgentLoopConfig` also supports `turn_delay: Option<Duration>` — an inter-turn delay to throttle API calls for rate-limit-sensitive providers. Exposed on `SubAgentTool` via `with_turn_delay()`.

### wasm32 and the `native` feature

yoagent builds for `wasm32-unknown-unknown` (Cloudflare Workers) with `--no-default-features`; the default `native` feature gates the filesystem/shell tools, `default_tools()`, `StdioTransport`, `FileBackend`, `PriceTable::fetch_cached`, `prices::enable_live*` / `LivePrices` and reqwest's default features (TLS — a native build without `native` compiles but cannot reach HTTPS providers). Every native-only public item carries `#[cfg_attr(docsrs, doc(cfg(feature = "native")))]`; tests, doctests and targets that need it are gated (`#[cfg(feature = "native")]`, `required-features = ["native"]`). Conventions for code that runs on both:
- **Tasks and timers via `crate::rt`** (`spawn`, `sleep`, `timeout`, `JoinHandle`, `Instant`): Tokio's own natively (paused-clock tests keep working), host executor / `setTimeout` / `performance.now()` on wasm32. Never `tokio::spawn` / `tokio::time::*` / `std::time::Instant::now` / `SystemTime::now` outside native-only modules and tests; wall-clock reads use `web_time`. The wasm32 CI job enforces this with `CLIPPY_CONF_DIR=.github/clippy-wasm32` (`disallowed-methods`); it cannot live in a root `clippy.toml`, because natively `rt::sleep` *is* `tokio::time::sleep`.
- **Trait bounds are `crate::rt::MaybeSend + MaybeSync`** (exactly `Send + Sync` natively, nothing on wasm32) for every extension trait that runs on wasm32, with `#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]` + `#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]` on the trait and its non-test impls. Boxed futures that must be `Send` natively get a wasm32 variant without it (see `evaluate_singles`).
- Provider SSE goes through the crate-private `sse::open_event_source` (`retry::Never`): the library's reconnect timer panics on wasm32 and would re-send a completion. (`sse` is a private module since 0.25; the unused `drive_sse` / `SseEvent` were removed.)

```bash
cargo clippy --all-targets --no-default-features            # native without `native` — CI's no-default row
CLIPPY_CONF_DIR=.github/clippy-wasm32 cargo clippy --target wasm32-unknown-unknown --no-default-features [--features decision]
# tests/wasm32.rs under Node (rt shims + whole agent runs); needs wasm-bindgen-cli at the resolved wasm-bindgen version
CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner cargo test --target wasm32-unknown-unknown --no-default-features --test wasm32
```

Native dev-dependencies (Tokio `full`, tempfile, wiremock) are under `[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]` because Cargo builds every dev-dependency for the target under test; only `--test wasm32` is built for wasm32, and a new wasm32 test belongs in that file, not in a native test target.

### Testing

All unit tests use `MockProvider` (`provider/mock.rs`) to simulate LLM responses without network. Test files are in `tests/` — `agent_test.rs`, `agent_loop_test.rs`, `tools_test.rs`. Follow the existing pattern of constructing a `MockProvider` with predetermined responses.

**Mutation testing** (`cargo-mutants`, #163) runs weekly, not per-PR, via `.github/workflows/mutants.yml` (16 round-robin shards with `CARGO_INCREMENTAL=1` on the mutants step (rust-cache exports 0, which made every mutant a from-scratch rebuild), pinned version in the workflow's `CARGO_MUTANTS_VERSION`; survivors are a report on the run summary, never a red X; a failing unmutated baseline is). Scope, features, the `mutants` profile (`Cargo.toml`: opt-level 1, no debuginfo) and the documented acceptable exclusions live in `.cargo/mutants.toml`. `docs/evals/mutation-baseline.md` holds the baseline and the triaged survivors — check a missed mutant against it before treating it as news, and add newly-accepted equivalents there (or to `exclude_re` with a comment saying why). Locally: never export `CARGO_TARGET_DIR` for it; each `-j` job does its own cold build (~2–3 GB, 12+ min) in `$TMPDIR`; `-f` does not narrow a run when the config sets `examine_globs` (copy the config and change the globs, or use `--shard k/n --sharding round-robin` for a sample).

### Telemetry

The loop emits `tracing` spans: `agent_loop` → `llm_stream` per turn (records tokens_in/out/cached + cost_usd from `CostConfig` when configured) → `tool` per execution (records is_error). Futures are instrumented with `.instrument(span)` (never hold an entered guard across `.await`); spawned runs, the extension observer and compaction tasks carry the caller's span and subscriber (`in_current_span` + `with_current_subscriber`). OTel is app-side via `tracing-opentelemetry` — the library has no OTel dependency by design.

### Pricing (`provider/prices.rs`, `provider/prices/{global,fetch}.rs`, `provider/prices.json`)

**Nothing is priced by default (0.25, breaking).** The process-wide table starts empty, so every constructor's `cost` is `None` and the decision presets are unpriced (free handles — `local()`, loopback `logprobs`, a zero-rate `cost` — stay $0). Opt-in, process-wide, before building configs: `prices::enable_bundled() -> Vec<PriceChange>` (the bundled snapshot; all targets) or, `native` only, `prices::enable_live(&source)` / `enable_live_cached(&source, path, CacheOptions)` `-> LivePrices { origin: PriceOrigin, changes, skipped, ignored_fields, cache_problem }` + `fell_back()` (fetch, then install the table as the fetched layer **over** the snapshot under one lock via crate-private `global::enable(Option<PriceTable>)`; a failed fetch → snapshot alone, `PriceOrigin::Builtin{fetch_error}`, `warn!`). `global::clear_bundled()` / `is_bundled_enabled()` / `pricing_enabled()` (resolved non-empty). `YOAGENT_PRICES`, `install_override` and `install_fetched` alone price exactly what they list. No pricing code makes a request unless called (`enable_live*`, `PriceTable::fetch*`).

Prices are data, not literals: `prices.json` (the **bundled snapshot**, embedded via `include_str!`, applied only on opt-in; schema 1, keyed provider → model id) is parsed into a validated `PriceTable` (plain data; every entry goes through `PriceTable::insert` → `PriceEntry::validate`; `PriceTable::builtin()` returns it as a value). First-party constructors whose provider is in `PRICED_PROVIDERS` (`anthropic`, `openai`/`openai_responses`, `google`, `xai`, `groq`, `deepseek`, `mistral`, `zai`, `minimax`, `qwen`, `meta`) set `cost` via a private `priced()` lookup at construction (it debug-asserts membership); the named presets inherit it through `..Self::anthropic(..)` etc. Gateways/custom endpoints never look up (`None`).

The lookup reads the process-wide resolved table managed by the free functions in `prices::global` (`OnceLock<RwLock<Layers>>`): user layer (`install_override` → `OverrideReport { changes, reverted, inert, warnings }`, or `YOAGENT_PRICES` read once at first use, status via `env_override_status() -> EnvOverride`) > fetched layer (`install_fetched` / `install_fetched_with(table, InstallPolicy::AddOnly)`, returns `Vec<PriceChange>` against the previous bundled+fetched table, `shadowed` marks changes the user layer hides; logs snapshot disagreements only while the snapshot is on) > bundled snapshot (`Layers::bundled`, off by default). Decision pricing reads `global::resolved_cost` at evaluation time; `is_read` counts `typesafe`, `cloudflare` and `openai-decisions` (decision feature). Each layer replaces whole entries; every install runs under one write lock. Constructors resolve at construction time: a `cost` set on a config wins over every layer, but `LlmCompaction::reprice()` / defaulted `CompactionStrategy::reprice` (reached by `Agent::reprice`) re-price the summarizer; a first-party lookup that misses while pricing is on but the snapshot is off warns once per model (`resolved_cost`). `ModelConfig::reprice()` (repeats the constructor lookup, may clear; only for configs a pricing constructor built — private `#[serde(skip)] list_priced` marker — whose provider is still in `PRICED_PROVIDERS`; also `Agent::reprice`, `SubAgentTool::reprice`) and `with_prices(&table)` (never clears, applies to gateways) overwrite it.

Live sources (`prices/fetch.rs`), never fetched implicitly: `PriceTable::fetch` / `fetch_with(source, FetchOptions) -> FetchReport { table, skipped, ignored_fields }` from `ModelsDev`/`ModelsDevAt` (mapped; inexpressible models skipped, `alibaba`→`qwen`, `opencode`→`opencode-zen`), `YoagentMain` (lenient), `Url` (strict); `fetch_cached(source, path, CacheOptions) -> CachedPrices` (cache records the source URL; `PriceOrigin` is `Fetched{cache_write_error}` / `Cache{age}` / `StaleCache{age, fetch_error}` / `Builtin{fetch_error}`; `cache_problem` reports unusable caches). Hand-written input is strict (`PriceError::UnknownField`); `YoagentMain` and caches are lenient and return ignored field paths. `PriceError` is `Clone` (I/O and transport errors behind `Arc`).

Tests that mutate the global layers or assert on logs live in their own serialized binaries (`price_override_test`, `price_env_test`, `price_env_bad_test`, `price_env_unset_test`, `price_explicit_cost_test`, `price_fetch_test`, `price_enable_test` (live opt-ins, wiremock), `price_default_test` (the unpriced default; one test)); unit tests never read `YOAGENT_PRICES`. Tests that assert a preset's cost call the idempotent `prices::enable_bundled()` first (lib unit tests included — never `clear_bundled` in a shared binary). `tests/preset_prices_test.rs` pins every preset's full `CostConfig` (after `enable_bundled`); `tests/price_audit.rs` diffs every entry against models.dev (`--ignored`), and runs the same comparison offline against `tests/fixtures/models_dev_slice.json`. Change a price by editing the JSON entry (and its `verified`/`source`), never by adding a literal.

## Key Design Conventions

- Context overflow detection is centralized in `OVERFLOW_PHRASES` (`provider/traits.rs`) covering 15+ provider-specific error strings; both HTTP errors and SSE-embedded errors are classified. Rate limits are checked first (HTTP 429; structured SSE `type`/`code`/`status` such as `too_many_requests`, `no_capacity`, `rate_limit_exceeded`) so a capacity error whose text resembles an overflow phrase is retried, not compacted
- Tools return stdout/stderr even on failure so the LLM can self-correct (a `bash` timeout returns the output so far)
- Built-in tool safety:
  - `PathSandbox::resolve` anchors relative paths at `current_dir()` first, keeps `..` components, re-resolves a path that climbs through a missing directory, and follows a dangling symlink (up to `MAX_SYMLINK_HOPS`; `None` means refused). `tests/sandbox_cwd_test.rs` changes the cwd, so it is its own binary.
  - Sandboxed tools do their I/O on `PathSandbox::io_path` (the checked path); unsandboxed tools use the caller's spelling.
  - `search` passes `--regexp=` / `-e`, then `--`, then the path. It has no per-file `--max-count`; it reads lines until `max_results` and kills the search at the next one (`details.truncated`). An exit 2 (some file errored) with matches returns the matches plus stderr under `Warnings:` (`details.warnings` = that text, capped, or null; kept on the early-stop path too); only an error with no matches fails.
  - `list_files` guards `find` with `not_an_option`. `find`'s stderr is read: errors (an unreadable subdirectory) are appended to the listing under `Warnings` and in `details.warnings` (capped), so a partial listing never reads as complete; only an error with no files fails (#260).
  - `bash` spawns with `kill_on_drop`, which kills the `bash` process only, and with `stdin(Stdio::null())` (`spawn` inherits stdin, while `output()` did not; `search` sets it too). Each stream goes through `Capture` (bytes kept ≤ `max_output_bytes`, the rest drained; a cut flag and a read error survive a timeout), and output is decoded after cutting.
  - Tool `execute` is built and awaited inside `catch_unwind` in `execute_single_tool`, the same as middleware. A panic is an error result carrying the payload text, logged at `error!` with the `tool_call_id`.
  - The Gemini key goes in `x-goog-api-key`, trimmed, never the URL. It is skipped when the user's headers set `x-goog-api-key`, or when the key is empty. An `Authorization` header doesn't count.
- Retry logic (`retry.rs`) uses exponential backoff with ±20% jitter; only retries `RateLimited` and `Network` errors (`classify` maps 429, 503 and 529 to `RateLimited` and `warn!`s the body, since `RateLimited` carries no message; `RATE_LIMIT_CODES` includes Anthropic's `overloaded_error` and Google's `unavailable`, and an in-stream numeric `code` of 503/529 counts — 429 / `RESOURCE_EXHAUSTED` in-stream stays `Api` to keep its message; other 5xx stay `Api`). In `stream_assistant_response` each attempt's forwarder is **drained, never aborted** (what consumers see must not depend on the scheduler), an attempt that sent `MessageStart` without `MessageEnd` is closed with `StopReason::Error` (`AttemptEvents::needs_end`), and a retried attempt is followed by `AgentEvent::ProviderRetry` — consumers that treat an error `MessageEnd` as failure look at the next event first (GASP holds it back; `release_smoke` / `long_horizon` clear it; `retry::RetrySafeEvents` / `retry_safe_events(rx)` is the consumer-side filter for append-only sinks: a private `Held` enum (Nothing / Open / Failed) holds an assistant attempt until its `MessageEnd`, drops a retried one down to its `ProviderRetry`, releases a final failure as start + `Error`/`Aborted` end without deltas, and drops (never releases, `warn!`s) an attempt left open by a retry, a second start or the stream's end; other events overtake a held attempt; a sub-agent's `ToolExecutionUpdate` text is not covered; the documented terminal pattern (abort on a `ProviderRetry` after streamed text) is tested in `tests/retry_safe_test.rs`). An `Ok` attempt that left its message open (a provider that never sent `Done`) is closed with the returned message; a failed forwarder `JoinError` is `warn!`ed. The backoff sleep races `cancel`; a cancellation ends the turn as `StopReason::Aborted` (not `Error`, so `on_error` is not called). A cancel caught at the top of a turn (while tools ran, or between turns) appends `CANCELLED_MARKER` (`[Agent stopped: cancelled]`, via `push_stop_marker`) when the run produced an assistant message; `sub_agent::extract_error` treats it, like a loop abort, as a failure (a limit marker is still partial success). `Batched { size: 0 }` runs as size 1. Providers must drop every `tx` clone before `stream` returns, or the drain waits
- The `skills.rs` module loads `<name>/SKILL.md` files with YAML frontmatter per the AgentSkills standard