# Changelog All notable changes to `yoagent` are documented here. The format loosely follows [Keep a Changelog](https://keepachangelog.com/), and the project adheres to [Semantic Versioning](https://semver.org/). ## 0.27.8 (2026-11-12) One security fix, no breaking changes. yoagent-rutis (0.13.0) and yoagent-workers (0.4.0) are unchanged. - **Provider requests no longer follow a redirect to another host.** A provider answering with a redirect to another origin had the request re-sent there with every header except `Authorization`, `Cookie` and `Proxy-Authorization`. That carried the key from Anthropic (`x-api-key`), Azure (`api-key`), Gemini (`x-goog-api-key`), Bedrock's session token (`x-amz-security-token`) and any credential in `ModelConfig::headers`. A 307 or 308 carried the whole prompt too. It was re-sent on every retry as well. A decision backend's redirected request carried its state and questions; its key, sent as `Authorization`, did not go. Every provider and decision backend now follows redirects only within the request's own origin (scheme, host and port), as the MCP HTTP transport already did by default. A redirect elsewhere fails the request at once with an error naming the other origin, and is not retried. Native targets only: on wasm32 the host's `fetch` follows redirects itself. ## 0.27.7 (2026-11-09) One preset, no breaking changes. yoagent-rutis (0.13.0) and yoagent-workers (0.4.0) are unchanged. - **A preset for Gemini 2.5 Flash-Lite.** `ModelConfig::gemini_2_5_flash_lite()`, and `ModelConfig::preset("google", "gemini-2.5-flash-lite")`, give it Google's limits (1,048,576 context, 65,536 output) instead of the generic constructor's guess, and its price: $0.10 / $0.40, cached $0.01 for text, image and video. Audio input bills three times that, so it is under-counted (see its `note`). Google does not deprecate this model (it "remains available through the API until further notice"), unlike 2.5 Flash and 2.5 Pro, which still have no preset. ## 0.27.6 (2026-11-09) Presets only, no breaking changes. yoagent-rutis (0.13.0) and yoagent-workers (0.4.0) are unchanged. - **Presets for every current Gemini text model.** `ModelConfig::gemini_3_5_flash_lite()`, `gemini_3_1_flash_lite()` and `gemini_3_flash_preview()` join the existing Gemini presets, so `ModelConfig::preset("google", id)` now returns every current Gemini 3 and later text model on Google's models page (read 2026-11-09) with its limits: 1,048,576 context and 65,536 output for all seven (Gemini 4 Argon's default cap; its long decode is not used). The Gemini 2.5 models (2.5 Flash and Pro, which Google routes to newer models from 2026-12-01, and 2.5 Flash-Lite) still have no preset. Prices from Google's pricing page: 3.5 Flash-Lite $0.30 / $2.50 (cached $0.03), 3.1 Flash-Lite $0.25 / $1.50 ($0.025), 3 Flash Preview $0.50 / $3 ($0.05). The last two bill audio input at twice the text rate, which a flat rate cannot carry, so their audio input is under-counted (see each `note`). The existing four presets were checked against the same pages and need no change (their `verified` dates move to 2026-11-09). ## 0.27.5 (2026-11-08) Fixes, no breaking changes: built-in tools (atomic writes, long `bash` output keeps its end, paged reads of large files), `LlmCompaction`'s transcript, MCP (paged tool lists, cancellation notices, session and exit reporting), provider streams (Anthropic cache bridge and usage, Gemini blocks and final events, OpenAI-compatible tool slots), the agent loop's stop markers and events, `Agent` changes made during a run, GASP recording one run, and `FileBackend` eviction. yoagent-rutis (0.13.0) and yoagent-workers (0.4.0) are unchanged. - **Anthropic: tool arguments that are not an object are not run.** `resolve_tool_arguments` took any JSON value, so an array or a double-encoded string became the call's arguments, and `null` ran with `null` — while every other provider reads `null` as `{}` and refuses other non-objects. `null` is now `{}`, as on the other providers; any other non-object is not runnable, and — as for unparseable JSON on Anthropic — the turn fails (the other providers instead answer that call with an error result). - **OpenAI-compatible: a tool-call index that skips a slot emits no empty call.** A stream whose calls start at index 1 (some gateways count from 1) left an untouched slot that was emitted as a `ToolCall` with no id and no name, which broke the next request. Untouched slots are dropped. - **Gemini: a last event without its blank line is kept.** At the end of the body the buffered bytes were discarded, so a final event a proxy sent without the trailing blank line — the one carrying `finishReason` — was lost: the complete response read as cut short and was retried, billing it twice. It is now processed. An event's `data:` lines are joined, as SSE specifies, instead of only the first being read. - **MCP over HTTP: a 404 ends only the session it was sent with.** It cleared whatever session id was current — a newer one (a re-initialize while the request was in flight) included — and a request sent without a session cleared it too. - **MCP over stdio: a server that crashed before `close` reads as exited.** `close` marked the server closed by the host before looking at it, so a crash nobody had polled read as "was closed (`close`)" — hiding it from a host that restarts crashed servers. `close` now looks first (a process tokio already collected counts as exited; an own-group leader is looked at without collecting it). - **GASP: a recording is one run.** A sender reused for a second prompt wrote that run's `run.started`, `model.called` and `model.finished` — and its plugin log lines — into the finished recording, after its `run.finished`. Events after the run ended are now dropped, with one warning, and only the recording's first top-level run and the runs it delegates (with the link as a tree extension) are linked for plugin lines (a refused run is logged at `debug`; lines dropped after the run warn once); a second `AgentStart` before an `AgentEnd` closes the recording to the rest of that sender; use a fresh `recording_sender` per run. - **`Agent`: a change made while a run is out is not overwritten by its join.** A setter (`set_tools`, `append_message`, `replace_messages`, `clear_messages`, `restore_messages`) called after `prompt` returned but before the run was joined (by `finish()` or the next run) changed a copy that the join then replaced — the usual case being events drained, then a message appended. Such a change is now applied on top of what the run hands back, in the order made (a run already ended is taken back at once). `with_tools` / `with_messages` go the same way, and `prompt_structured` joins an earlier run before it reads the history. Until the join, `messages()` shows the history as the run started (so `replace_messages` with a copy taken then replaces the run's output: use `append_message`, or `finish()` first). `reset` keeps a queued tool change and drops queued history changes. - **A loop-detection abort reports its unexecuted results as events.** The "Not executed" tool results it writes for the stuck response's calls went into the history without `ToolExecutionStart`/`End` or `MessageStart`/`End`, and that turn's `TurnEnd` listed no results, so a transcript rebuilt from events (GASP, a frontend) had unanswered tool calls. They now go through the same events as every other unexecuted call. - **`on_before_turn` ending a run is a stop, not a completion.** Returning `false` closed the turn without a marker, so the run's outcome read as `Completed` — and a sub-agent ended that way reported a full answer. It now appends `[Agent stopped: on_before_turn ended the run]` like every other stop (a limit, loop detection, an extension's `Stop`), and the outcome is `Stopped`. A history now holds that marker message. - **A run's ending is no longer read from text that only looks like a marker.** The outcome extensions see was decided by the last message's prefix, so a prompt or steering message beginning `[Agent stopped:` ended a cancelled run as `Stopped`. The loop now records where it put its stop marker, and only that one counts. - **Gemini: a function call in a blocked or failed response is not run.** Once a `functionCall` had set the turn to `ToolUse`, every later `finishReason` was ignored ("Gemini returns STOP even with function calls"), so a response ending `SAFETY`, `PROHIBITED_CONTENT`, `MALFORMED_FUNCTION_CALL` or `TOO_MANY_TOOL_CALLS` still ran its calls. `STOP`, `MAX_TOKENS` and unrecognised reasons keep the calls; a block is a `Refusal` and a tool-call failure an `Error`, whose calls the loop never runs (as on Bedrock). A blocked prompt stays refused whatever follows it (a call, then a `STOP`). - **`FileBackend` evicts scoped stash files.** Eviction classified each file's *name*, where the scope separator is encoded (`%1F`), so a scoped sub-agent's stashed tool output read as a caller's key and was never evicted — the store grew past `max_bytes`. It now classifies the decoded key. - **`search` with `max_results: 0` no longer hides matches.** The first match ended the search with nothing kept, and the call answered "No matches found". A cap of 0 now means 1. - **The `LlmCompaction` "losing every race" warning ignores fallbacks before the first request.** Fallbacks before the first summarization request (nothing to race yet) counted toward the streak, so one real miss after the first request could complete it and claim a cost being wasted. - **`LlmCompaction` summarizes the right part of a long span.** Past the transcript's 480 KB cap it kept the **oldest** messages and dropped the newest, while its comment, warning and marker said the opposite; the marker sat at the end. It now fills the cap newest first, with the marker where the omitted messages were. An earlier briefing (the previous summary, at the start of the next span) was clipped to the 2,000-byte per-block cap on every recompaction — about a quarter of a default 2,000-token summary — and the latest real user request was clipped the same way. Both are now pinned — the briefing, and the latest real user request within the summarized span: each text block clipped at 32 KB instead, and kept whatever the total — so the summarizer sees what was already summarized and the request that drove the span. Messages that serialize to nothing (extension messages, empty assistant turns) no longer split the omitted region, one warning per compaction gives the total, and newest messages that do not fit are reported too. - **MCP `tools/list` follows every page.** `McpClient::list_tools` sent one request and never read `nextCursor`, so a server that paginates its tools lost every tool after the first page — for `with_mcp_server_*` and `McpToolSource` alike, without a warning. It now follows the cursor to the end; a repeated cursor, or more than 1,000 pages, is an `McpError::Protocol` instead of a loop or a silently partial list. - **A cancelled MCP call tells the server.** A `tools/call` dropped before its answer (the run cancelled, or the call timeout hit) now sends one best-effort `notifications/cancelled` with its `requestId`, as the spec asks, so the server can stop the work; before, it carried on and only its late answer was discarded. Sent from a spawned task within 5 s (a custom transport's default `notify` waits for an answer that never comes); a failure is logged at `debug`. - **Anthropic: the history cache survives a large batch of parallel tool calls.** Anthropic looks back only about 20 content blocks from a breakpoint for an earlier cache entry, and yoagent put a single breakpoint on the history (the second-to-last message). A response with many parallel calls — each call a block, each result its own message — moved it past that reach, so from about 10 calls every turn wrote the whole history again instead of reading it. A fourth breakpoint (Anthropic allows four; yoagent used three) now bridges: on the message just before the newest assistant message, where the previous request ended, when 16 or more blocks separate it from the main one. Smaller turns are unchanged. - **Anthropic: a `null` usage count no longer zeroes the usage.** The counts were plain integers, so a `null` (the API types allow it) failed the whole `message_start` — or `message_delta`, losing its stop reason too — and the event was skipped without a word. They now read `null` as 0 (logged once). `message_delta`'s input and cache counts, which restate the totals, are now read when non-zero. - **An in-stream rate limit retries after the wait it names.** An error event like OpenAI's "Please try again in 2.3s" (also `120ms`, `1m30s`) has no `retry-after` header, so the retry used the default backoff and could spend its attempts before the limit reset. A named wait of a second or more now becomes `retry_after_ms` (and counts against `max_retry_after`); a shorter one is left to the backoff, which waits at least that long and keeps the retries from all firing within a second. - **`bash` keeps the end of long output.** Past `max_output_bytes` (256 KB) it kept only the start and drained the rest, so a long build or test log lost its verdict, which comes last. The cap is now split between the first half and the last half of each stream, with `... (output truncated: N bytes omitted) ...` between them; output under the cap is unchanged. - **`write_file` and `edit_file` replace a file whole.** They wrote in place, truncating first, so a crash, a full disk or a write error part-way left the file truncated and the original lost. They now write a temp file in the same directory, sync it and rename it over the target: the old content stays intact until the new one is complete, and a reader never sees a half-written file. What an in-place write did is kept: a file that could not be written in place (read-only, another user's) is refused, not replaced; a symlink is followed (its target replaced, the link kept; a dangling one is written through); the file's mode is kept, the temp file is created with it (new content is never briefly more readable than the old), and on Unix the owner and group are kept (a file the process cannot give back to its owner — another user's — is written in place instead); a directory where no file can be created, or a sticky directory refusing the rename, falls back to writing in place. A hard link to the old file keeps the old content, and ACLs and extended attributes are not carried over. - **`read_file` pages through files over its size limit.** A file over `max_bytes` (1 MB) was refused before `offset`/`limit` were read, though the error said to use them. With `offset` or `limit` it is now read in fixed chunks, so memory stays bounded: lines before the page are only counted, a kept line is cut at `max_bytes` (the header names it, and the read-before-overwrite guard never counts it as read), the page stops once it holds `max_bytes` (each line's number counted), the header gives the true line count, and a cancel ends the scan. - **`edit_file` no longer suggests `write_file` for a file it cannot read.** Any read failure said "Use write_file to create new files", which steered the model into overwriting a non-UTF-8 file. Only a missing file says so now; a non-UTF-8 one says edit_file edits UTF-8 text only and the file was not modified. - **`bash`'s environment allow-list survives a non-Unicode variable.** It used `std::env::vars()`, which panics on any variable that is not valid Unicode — every call failed (or, called outside the agent loop, panicked) in such an environment. It now reads `vars_os`. ## 0.27.4 (2026-11-07) A fix: no `temperature` for GPT-6. yoagent-rutis (0.13.0) and yoagent-workers (0.4.0) are unchanged. - **No `temperature` for GPT-6.** OpenAI's latest-model guide: "When reasoning effort is not `none`, remove `temperature`, `top_p`, and `top_logprobs`". No GPT-6 model defaults to `none` (Astra and 6.1 Sol do not support it), and yoagent never sends `none` — `ThinkingLevel::Off` sends no effort, leaving the default — so `with_temperature` on GPT-6 failed every request (its preset docs said not to set one). `ModelConfig::openai` and `openai_responses` now set `accepts_sampling_params` from the id, like `anthropic` and the OpenCode gateways, and a GPT-6 id (`gpt-6…`, after any `vendor/` prefix) turns it off: the GPT-6 presets, `generic("openai", ..)`, OpenCode and OpenRouter configs for one included, so the agent loop leaves the temperature out. OpenAI's other reasoning models keep the flag on (their default effort may be `none`); set it with `with_accepts_sampling_params(false)` if yours rejects one. ## 0.27.3 (2026-11-07) A fix: no `temperature` for a model that rejects it. yoagent-rutis (0.13.0) and yoagent-workers (0.4.0) are unchanged. - **No `temperature` for a model that rejects it** (asked for by yoyo-evolve, its #1146). Claude models from 4.7 on (Opus 4.7 / 4.8 / 5 / 5.5, Sonnet 5 / 5.5, Haiku 5.5, Fable) reject `temperature`, `top_p` and `top_k` with a 400 on the API version yoagent sends, so `Agent::with_temperature` / `SubAgentTool::with_temperature` made every request to them fail. New `ModelConfig::accepts_sampling_params` (default `true`, today's behaviour; `with_accepts_sampling_params`): `ModelConfig::anthropic`, the OpenCode gateways and every preset built on them (Bedrock and OpenRouter included) turn it off for a Claude id from 4.7 on, and the agent loop then leaves the temperature out, logging that once per model at `warn`. A host reads the field to tell its user the value was dropped. Claude 4.6 and earlier (Haiku 4.5 included) keep it. Any other gateway (`openai_compat`, `local`, `ollama`) or a `custom` config is never inferred: set the flag yourself. The flag is read from the run's `ModelConfig`; a loop run with none (`AgentLoopConfig::new` alone) sends the temperature as given. A config persisted before this release deserializes with the flag on, so rebuild it to get the inference. `ModelConfig` is `#[non_exhaustive]`, so the new field is additive. ## 0.27.2 (2026-11-06) Additive: model limits as data, three Claude Opus rows, and `ModelConfig::bedrock_anthropic_id`. yoagent-rutis (0.13.0) and yoagent-workers (0.4.0) are unchanged. - **Model limits as data** (asked for by yoyo-evolve, its #1141). A price-table entry may state the model's `context_window` and `max_output`; a first-party constructor uses them for a model with **no named preset** — the context window, and `max_tokens` as half of `max_output`, at most 64,000 (the presets' rule), never below the provider's own default unless that exceeds `max_output` — so such a model is no longer left on the provider's generic defaults and the "limits are guesses" warning. **The bundled snapshot's limits always apply, whether or not pricing is enabled** (prices stay opt-in); a user table's limits win over the snapshot's, and the snapshot's (checked against the vendor) over a fetched table's, which only fill models the snapshot does not list; a named preset's own limits win over all. A config built before the table listed its model still gets the "limits are guesses" warning, now hinting to build it again (or to ignore it if the limits were set on purpose). models.dev's `limit.context` / `limit.output` are mapped (unusable ones are dropped, the model keeping its price, and logged at `debug`). `ModelConfig::preset("openrouter", "vendor/model")` also finds a vendor model the table gives limits. `PriceEntry` gains `context_window` / `max_output` (`with_limits`; `#[non_exhaustive]`, so additive); not a billing field, so no `schema` bump. Adding a model's limits is now one verified data row, not a constructor and a release. - **Claude Opus 4.7, 4.6 and 4.5** (legacy, still served): entries with limits and prices from each model's page and the pricing page — 1M / 128K, 1M / 128K, 200K / 64K; $5 / $25, cache read $0.50, 5-minute cache write $6.25 — so `ModelConfig::anthropic("claude-opus-4-6", ..)` gets 1M context and 64K `max_tokens`, and `preset("openrouter", "anthropic/claude-opus-4.6")` resolves. No bundled row for Claude Opus 4.1 (retired 2026-08-05) or Sonnet 4.5 (retiring 2026-11-30), per Anthropic's deprecations page; live models.dev data, once enabled, may still list them. - **`ModelConfig::bedrock_anthropic_id(id)`** (asked for by yoyo-evolve, its #1124): the Anthropic model id behind a Bedrock model id or cross-Region inference profile id — `"us.anthropic.claude-sonnet-4-6"` → `"claude-sonnet-4-6"`, `"anthropic.claude-haiku-4-5-20251001-v1:0"` → `"claude-haiku-4-5"` — by exactly the rules `preset("bedrock", ..)` uses (it is now built on it), `None` where that finds nothing; only Claude models with a yoagent preset are mapped. The Bedrock preset stays unpriced (Bedrock bills its own rates, with a 10% regional premium); with the Anthropic id a host looks Anthropic's rates up (`ModelConfig::preset("anthropic", &id)`) and applies its own Bedrock rule, instead of keeping a second Bedrock-id mapping. Anthropic ids only (hence the name), returned as a `String`. ## 0.27.1 (2026-11-05) A fix: no panic when the HTTP client cannot be built. yoagent-rutis (0.13.0) and yoagent-workers (0.4.0) are unchanged. - **No panic when the HTTP client cannot be built** (asked for by yoyo-evolve, its #1138). Every provider built its client with `reqwest::Client::new()`, which panics when TLS cannot be set up — on Linux and other non-Apple Unix, `SSL_CERT_FILE` / `SSL_CERT_DIR` naming nothing that loads (an empty, missing or non-PEM file, an empty directory), or a host with no CA bundle ("No CA certificates were loaded from the system") — killing the task running the agent; the decision backends (`SystemOneBackend`, `OpenAiDecisionBackend`, `LogprobBackend`, so every `DecisionModel` preset) did the same in their constructors. Now a provider request returns `ProviderError::Other` (not retried): "cannot build the HTTP client (…). Usually the CA certificates: SSL_CERT_FILE / SSL_CERT_DIR, if set, must name PEM certificates (a file, or directories of them) that load; if neither is set, install the system's CA bundle (e.g. ca-certificates) or point SSL_CERT_FILE at one." A decision backend builds without a panic, logs that at `warn!`, and returns it as `DecisionError::Backend` (not retried) from each request. MCP, OpenAPI and the price fetch already returned an error. A test scans `src/` so a panicking `Client::new()` cannot come back. ## yoagent-rutis 0.13.0 (2026-11-04) yoagent-rutis only; requires yoagent 0.27 (was 0.26), rutis 0.20 and Rust 1.88. Plugins' `on_stop` now also sees an answer cut off at the output limit, and the `Stop` event carries `stop_reason` (`Stop::stop_reason`; JSON `"stop"` / `"length"`, the vocabulary `finish`'s `stop_reason` uses), so a verifier can tell a truncated answer from a finished one and send the model back to finish it. `Stop` is `#[non_exhaustive]`, so the field is additive. Released with **yoagent-workers 0.4.0**, a requirement bump to yoagent 0.27 and nothing else. ## 0.27.0 (2026-11-04) **Breaking (struct literals only):** `ReadFileTool`, `WriteFileTool` and `BashTool` gain public fields (`read_log`, `max_image_bytes`, `raw_output`); literals using `..Default::default()` are unaffected. **Behaviour changes to know:** `write_file` from `default_tools()` refuses to overwrite a file read only in part; `read_file` refuses images a provider would refuse (GIF, BMP, HEIC…, over 8000 px or 3.75 MB, down from 20 MB); `on_stop` also judges answers cut off at the output limit; Gemini's `RECITATION` is a refusal; images from tool results reach OpenAI-compatible models in a user message; tool calls a run left unanswered are answered (never re-run) when the next run starts. Ships with yoagent-rutis 0.13.0 and yoagent-workers 0.4.0. The library's design principles are now written down in `CLAUDE.md` / `docs/design-philosophy.md` (#420). - **Breaking (struct literals only):** `ReadFileTool` and `WriteFileTool` gain a public `read_log: Option` field; struct literals using `..Default::default()` are unaffected. **`write_file` no longer overwrites a file the agent read only in part** (bug fix; prior art: yoyo-evolve's partial-read guard). `read_file` pages long files (500 lines by default) and `write_file` replaced the whole file without checking, so a model that read lines 1–500 of a 2,140-line file and wrote it back silently deleted lines 501–2,140. The tools now share a `ReadLog` (`tools::ReadLog`, a cloneable handle): `write_file` refuses an **existing** file whose latest read covered only part of it, with an error naming the lines read and the file's length and pointing to `read_file` offset/limit or `edit_file`. Pages add up (1–500 then 501–2,140 is the whole file); a file read in full and unchanged since (later pages of that version included), the agent's own successful write, a file never read and a path that does not exist are allowed; a file changed on disk (length or mtime) since the partial read stays refused until read again; a read showing no lines records nothing. Paths are compared after the sandbox's resolution (`./a.rs` = `a.rs`), sandboxed or not. `edit_file` is unchanged. **Behaviour change only for `default_tools()`**, which wires one log into its `read_file` and `write_file` (one per call — one per agent), and for tools given a log with the new `ReadFileTool::with_read_log` / `WriteFileTool::with_read_log`; tools built on their own keep today's behaviour. The field defaults to `None`: a struct literal naming every field without `..Default::default()` needs it added. The log lives as long as the tools: `Agent::reset()`, compaction and a new sub-agent delegation do not clear it. - **`bash` names the signal that killed the shell.** When the `bash` process itself is killed by a signal (Unix) — the shell is killed, or the signal hits the command's last simple command or an `exec`ed one, which bash runs in its own place — the result used to read `Exit code: -1`; it now reads `Killed by signal 9 (SIGKILL)`, and `details.signal` carries the number (`null` for an ordinary exit, and always off Unix). A command killed earlier in a compound script is reported by bash itself as `Exit code: 128+n`, unchanged. `details.exit_code` keeps its meaning: the exit status, `-1` when there is none. - **`bash` strips ANSI escape sequences** from stdout and stderr before they reach the model (the timeout error's output too): CSI (colours, cursor movement), OSC (terminal titles; a hyperlink keeps its text), DCS/SOS/PM/APC and two-character escapes. A string sequence is removed up to its BEL, ST (`ESC \` or U+009C) or CAN/SUB (which abort it) when that comes within 4 KiB (64 KiB for DCS); an unterminated sequence (none within the bound) loses only its introducer, so a stray `ESC ]` cannot hide the output after it. Carriage returns, tabs and backspaces are kept; a sequence the output cap cuts in half is dropped. Linear in the output's length. `BashTool::with_raw_output(true)` (new `raw_output` field, default `false`) keeps the output as printed. **Breaking (struct literals only):** `BashTool` gains `raw_output`; literals using `..Default::default()` are unaffected. - **`edit_file` keeps a CRLF file's line endings.** Models write line breaks as `\n` and cannot see a file's `\r`, so a multi-line edit to a CRLF file failed with "old_text not found", and an edit that did match wrote bare `\n` into the file. An `old_text` not found as given is now looked up with each bare `\n` spelled `\r\n` (it must still match exactly once), and `new_text` is written with the line endings of the place it goes: `\r\n` where the matched text's breaks are all `\r\n`, or, for a match within one line, where that line ends in `\r\n` (a mixed file follows the matched region). `details.crlf` says when either text was respelled. An exact match always wins, and an LF file is never touched. - **`edit_file`'s "did you mean" snippet is numbered and bounded**: lines carry `read_file`-style numbers (` 12 | …`), each cut to 400 characters (`… (line truncated)`). Past 20 lines it shows 20 around the first line that differs from `old_text`, counting what it leaves out above and below. One line of a minified file used to put the whole line into the error. - **`SubAgentTool::with_max_depth(n)`** (opt-in; default no cap): a call that would start a run deeper than `n` fails at once with a tool error naming both depths and ending "Do this task yourself instead of delegating it.", and no run starts. Depth 1 is a run delegated by a top-level run (`ToolContext::delegation_depth`), so `with_max_depth(1)` lets a top-level agent delegate but not its sub-agents. - **A maximum server-requested wait** (opt-in): `Agent::with_max_retry_after(Duration)` / `SubAgentTool::with_max_retry_after` / `AgentLoopConfig::max_retry_after`. A retryable error whose `Retry-After` exceeds it ends the turn at once as an `Error` naming the wait (and a `warn!` saying why), instead of retrying into the same limit; a wait equal to it is retried. It only decides whether to give up at once: every wait still made is capped at `max_delay_ms` (30 s by default). Errors that name no wait are retried as before; unset (the default) changes nothing. On the loop config rather than `RetryConfig`, whose fields are all public: a new field there would break every struct literal. - **A warning when a model's limits are guesses.** A first-party config (`openai`, `xai`, `google`, …, or `ModelConfig::generic`) whose context window and output limit are still the constructor's generic defaults (e.g. 128K / 4,096 for an unknown OpenAI id), with no preset of those limits for its provider and id, now logs one `warn!` per model per process when it becomes an agent's (`Agent::from_config` / `from_provider` / `set_model`, `SubAgentTool`'s constructors), naming the preset when one exists. The context window drives compaction and the output limit caps every answer. Set `context_window` / `max_tokens`, or use the preset. Only configs a first-party generic constructor built, still at its base URL, are checked: gateways, custom endpoints (`openai_compat`, `custom`, a changed `base_url`, `anthropic` with a non-`claude-` id) and local servers are not, whatever their `provider` string. - **Parallel edits of one file no longer lose an edit.** The loop runs a response's tool calls at once, and `edit_file` reads a file then writes it back: two edits of one file interleaved, the last write won, and the other edit was gone while both calls reported success (32 parallel edits of one file left 1; a `write_file` racing an `edit_file` could be undone by an edit of the old content). `read_file`, `write_file` and `edit_file` now take turns on a file, process-wide (sub-agents included), keyed by its resolved path, so `a/../f.txt` and `f.txt` are one file; a call whose run was cancelled while it waited does not act. Not a lock against other processes. - **Breaking (struct literals only):** `ReadFileTool` gains a public `max_image_bytes: u64` field (`with_max_image_bytes`; default `tools::DEFAULT_MAX_IMAGE_BYTES`, 3.75 MB = 3,750,000 bytes, **down from 20 MB**). **`read_file` refuses images a provider would refuse**, since an image block a provider refuses stays in the history and fails every later request too. For a file named as an image, the type now comes from its first bytes (a JPEG saved as `.png` was sent as `image/png`, which Anthropic refuses). Sent: PNG, JPEG and WebP, what every built-in provider with image input takes. Refused, each with a hint to convert or scale it: a file named as an image that is none of them; GIF (Gemini does not list it, OpenAI takes only still ones; GIF used to be sent), BMP (used to be sent), TIFF, HEIC, HEIF, AVIF, ICO and JPEG XL; an animated WebP; a JPEG coded lossless, hierarchical, arithmetic or over 8 bits a sample; an image whose header gives no size or a side over 8000 px; an image over 3.75 MB (5,000,000 bytes once base64-encoded, under Anthropic's 5 MiB; Bedrock's limit is 3.75 MB). A file too large to send is judged on its first 64 KiB, so a non-image or refused format is told so rather than "too large". Not checked: Anthropic's 2000 px limit once a request carries more than 20 images, which depends on the conversation, not the file. - **`on_stop` judges an answer cut off at the output limit** (`StopReason::Length`) as well as a finished one. Skipping it let a truncated answer end the run past every verifier, a required one included; now a verifier can `Continue` (the model finishes its answer) or fail the run, and a required verifier that never accepts fails it at the cap as it would a finished answer. `stop.answer`'s `stop_reason` tells the two apart. `MockResponse::TextCutOff` scripts such an answer. **Behaviour change for existing verifiers:** an `on_stop` that never looks at the stop reason now also judges cut-off answers. **yoagent-rutis:** plugins' `on_stop` sees cut-off answers too, with `Stop::stop_reason` (JSON `stop_reason: "stop" | "length"`, the vocabulary `finish`'s `stop_reason` already uses). - **Gemini's `RECITATION` finish is a refusal** (`StopReason::Refusal`, with an explanation in `error_message`), not `Length`: the response was stopped for reciting its sources, not cut off, and asking the model to go on returns the same. Gemini and Vertex AI. - **Images from tool results reach OpenAI-compatible models.** A `tool` message may carry only text (OpenAI's spec: "For tool messages, only type `text` is supported"), and yoagent put a tool result's images there: OpenAI answered **400** ("Image URLs are only allowed for messages with role 'user'"), so a `read_file` of an image broke the conversation for good, and DeepSeek answered 200 without seeing the image (it described one it made up). The tool message now says where the image went, and one user message after the tool messages carries the images, in order, each tool's under `From ():` (after the assistant message a provider with `requires_assistant_after_tool_result` needs). Checked live: `gpt-4o-mini` via OpenRouter reads a PNG through `read_file` and names its colour. The Responses API takes images in a function output and is unchanged. - **`ModelConfig::image_input`** (`with_image_input(false)`; default `true`): for a text-only model, every image in the request is sent as a short note instead; the history keeps the image. A text-only model refuses a request with an image (OpenRouter: 404 "No endpoints found that support image input"), and the image stays in the history, so every later request failed too. Checked live with Llama 3.3 70B: on, the run fails; off, it finishes. `ModelConfig` is `#[non_exhaustive]`, so this is additive. - **A history with an unanswered tool call can be continued and prompted.** A run killed while its tools ran, or a session saved mid-turn, leaves tool calls with no result. `continue_loop` / `agent_loop_continue` panicked ("Cannot continue from assistant message"), and a new prompt sent a request every provider refuses, failing every later one too. The usual way: a response that ended in an error or a cancel keeps its tool calls, and the loop runs none of them; prompting again then left them unanswered mid-history, so histories saved before 0.27 can hold such calls. Every run that sends a request now first answers each such call with an error result, right after the call's turn: "Not run: the response that made this call ended in an error, a refusal or a cancel, so none of its tools ran." for those, otherwise "No result was recorded for this call: the run that made it ended before it returned. It may have run in part, in full or not at all; check the current state before calling it again." Each is logged at `warn!` and, when it ends the history, reported as a message of the run; one before the last turn is answered in place, so a `Session` path holding it no longer matches (`append_new` → `HistoryDiverged`). A result answers the nearest call before it with its id (Gemini's made-up ids such as `google-fc-0` recur turn after turn). The calls are never run again. Checked live: OpenAI accepts the repaired history and the model reports the write did not complete. ## 0.26.8 (2026-11-03) Additive: MCP stdio servers can be started without the host's variables. yoagent-rutis (0.12.0) and yoagent-workers (0.3.0) are unchanged. - **MCP stdio: keep the host's variables from a server** (asked for by yoyo-evolve, its #1125). `StdioOptions::env_remove(name)` / `env_remove_all(names)` start the server with those variables **unset** — not set to `""`, which is still a value and breaks some servers — and `env_clear_except(names)` passes only an allow-list. Applied as clear (keeping the allow-list's variables that are set) → remove → the caller's `env` map (a value set there wins). Names are `OsString`. `StdioOptions::new().env_remove_all(provider::api_key_variables())` keeps every provider key yoagent knows out of a server. `Agent::with_mcp_server_stdio_with(command, args, env, options)` takes the options on the `Agent` path too (`with_mcp_server_stdio` keeps the defaults). ## 0.26.7 (2026-11-03) Additive: three presets, `ModelConfig::preset` reads Bedrock and OpenRouter ids, and the agent skill is refreshed. yoagent-rutis (0.12.0) and yoagent-workers (0.3.0) are unchanged. - **`ModelConfig::claude_sonnet_4_6()`** (`claude-sonnet-4-6`, a legacy model Anthropic still serves): 1M context, 64K of its 128K max output by default, adaptive thinking, native structured outputs. Priced (after `prices::enable_bundled()`) at $3 / $15, cached $0.30, 5-minute cache write $3.75, no long-context tier — Anthropic's pricing page, 2026-11-03. - **Gemini 3.6 Flash and 3.1 Pro Preview**: `ModelConfig::gemini_3_6_flash()` (`gemini-3.6-flash`, stable) and `ModelConfig::gemini_3_1_pro_preview()` (`gemini-3.1-pro-preview`), both 1,048,576 context / 65,536 output. 3.6 Flash is priced at the regular $1.50 / $7.50, cached $0.15 (Google bills half through 2026-12-31, so costs read up to twice the bill until then, as for 3.8 Flash); 3.1 Pro at $2 / $12, cached $0.20, and $4 / $18 / $0.40 for prompts over 200,000 tokens. `gemini-3.5-flash` and `gemini-3.7-flash` are deprecated by Google (their requests are routed to 3.6 and 3.8 Flash) and get no preset. - **`ModelConfig::preset("bedrock", id)`**: a Claude preset on Amazon Bedrock by Bedrock model id or cross-Region inference profile id (`anthropic.claude-sonnet-4-6`, `us.anthropic.claude-sonnet-4-6`, `global.…`; prefixes `us`, `us-gov`, `eu`, `apac`, `jp`, `au`, `ca`, `in`; Haiku 4.5 as `anthropic.claude-haiku-4-5-20251001-v1:0`). The config is `BedrockConverseStream` on the `us-east-1` endpoint (set `base_url` for another Region) with the Anthropic preset's limits and **no price**: Bedrock bills its own rates, 10% higher on regional and geographic profiles. ARNs, other vendors and any other spelling find nothing. - **`ModelConfig::preset("openrouter", "vendor/model")`**: a vendor preset behind OpenRouter (`https://openrouter.ai/api/v1`, `OpenAiCompat::openrouter()`) for `anthropic`, `openai`, `google`, `x-ai`, `z-ai` and `qwen`, with the preset's limits and no price (a gateway). Model ids as OpenRouter lists them: Claude versions take a dot (`anthropic/claude-sonnet-4.6`); the dashed spelling and `:batch` variants find nothing. - **The `yoagent` skill** (`skills/yoagent/SKILL.md`, shipped in the crate): 14 more rows in its feature map (streaming, skills, compaction, persistence, shared state, custom extensions and delegation, decision models, MCP auth, live prices, OpenAPI, Workers, …), a how-to for running pi extensions and DSH plugins through yoagent-rutis, the missing yoagent-rutis 0.3.1 / 0.2.0 history, and accuracy fixes (#415). `tests/skill_test.rs` now also checks every repository URL and anchor the skill cites. ## 0.26.6 (2026-11-02) Additive: MCP over HTTP takes credentials that change during a session, and reports a server's sign-in challenge. yoagent-rutis (0.12.0) and yoagent-workers (0.3.0) are unchanged. - **MCP over HTTP: credentials that change during a session** (for yobuild's OAuth sign-in to remote servers). `HttpTransport::with_authorization(Arc)`: the source is asked for the `Authorization` value before every request (each POST and the closing `DELETE`); on a 401 its `challenged(&AuthChallenge)` (status, every `WWW-Authenticate`, the request, and `refused()` — the value the server refused, for collapsing concurrent refreshes; `AuthChallenge::new` builds one for tests) may return a new value, and that request is sent once more with the same MCP session — an expiring OAuth access token no longer needs a new transport. `Ok(None)` sends no header; a source error fails the request unsent (a `Transport` error naming the request); the refused value returned again is not retried; a 403 is never retried; a refreshed value refused too fails as `Http` 401 saying so. Values are marked sensitive and redacted from what the transport quotes (server bodies, JSON-RPC error messages); an invalid value is refused unsent and unechoed; a fixed `authorization` header and a source together are an error. The closing `DELETE`'s 401 / 403 is now logged at `warn` (the session may leak). - **Credential headers set with `with_header` are redacted too**: the value of `authorization`, `proxy-authorization`, `cookie`, or a header whose name contains `key`, `token`, `secret`, `auth` or `session`, echoed into an error body or JSON-RPC error message, becomes `[redacted]` (like the URL's key-like parts since 0.26.1). - **`McpError::Http` carries the `WWW-Authenticate` challenge** of a 401 / 403 (`err.www_authenticate()`; several headers joined with `, `), with or without a source — the server's sign-in or `insufficient_scope` details. A new field on the `#[non_exhaustive]` variant: not breaking. ## 0.26.5 (2026-11-02) Additive: an opt-in way to count `max_total_tokens`; nothing changes unless you set it. Released with **yoagent-rutis 0.12.0** (rutis 0.20.0, below); yoagent-workers (0.3.0) is unchanged. - **`max_total_tokens` can count the conversation's growth instead of every prompt** (#236, opt-in): `ExecutionLimits::with_token_accounting(TokenAccounting::Growth)` counts what the run adds to the conversation — the first turn's output, then each turn's growth over the turn before; the conversation the run starts with (history, system prompt, tools) is the baseline, so a run late in a long conversation gets the same allowance as the first. It grows with the conversation, not with turns × context, cached or not; a compaction's shrink, and a turn that reports no usage, count as zero. The default, `TokenAccounting::Processed`, is unchanged (every turn's whole prompt plus output), so no limit trips at a different point unless you opt in. Also `ExecutionTracker::record_turn_usage(prompt, output)`, which the loop now calls; `record_turn(tokens)` still adds its tokens as is. `ExecutionLimits` stored without the new field deserializes to `Processed`. ## yoagent-rutis 0.12.0 (2026-11-02) yoagent-rutis only; requires rutis 0.20.0 or a later 0.20, yoagent 0.26 and Rust 1.88 (unchanged). Breaking: rutis 0.20 (its types are in this crate's API) and the runtimes with it. - **rutis 0.20.0** (from 0.19.3). `rutis`, `rutis-bridge`, `rutis-loader` and `rutis-host` 0.20.0; move the runtimes with them: npm `@arcships/rutis`, `@arcships/rutis-runtime`, `@arcships/rutis-bun` 0.20.0, PyPI `rutis` 0.20.0 (Python 3.12+), Go module `github.com/arcships/rutis/go/rutis` v0.20.0 (Go 1.24+); Node 22.19+ and Bun 1.4+ as before. rutis 0.20 kept protocol 4, so a 0.19 runtime still greets a 0.20 host — but it runs its plugins on 0.19's rules (no `optional`, the old `use` lookup and unload order), which the pi and DSH adapters below no longer suit: pin every runtime to 0.20 too. The Rust API this crate uses did not change: a host that builds on yoagent-rutis 0.11 needs only the version bumps (a `rutis_bridge::runtime::Mount` literal of its own gains `greeting: None`, or use `..Mount::default()`). - **The pi and DSH adapters declare the services they look up per call** (rutis #1000: `use` and `get` read only what the plugin or its row declares, whatever else the runtime has). On 0.19 a plugin's `use` found a name another row in the same Node process declared; the adapters relied on that for their optional services, which on 0.20 would have read as absent with no error: pi's dialogs (`host-ui.ts`), `exec: "gate"` (`host-approval.ts`) and `wakeFor` (`host-session.ts`), and the DSH adapter's images (`attachments`) and stand-in agent (`yoagent-dsh-host`). The pi adapter now declares `optional: ['pi-host-ui', 'pi-host-approval', 'pi-host-session']` and the DSH adapter `optional: ['attachments', 'yoagent-dsh-host']`, read with `ctx.get` (rutis #999). Nothing changes for a host: the re-providing rows (`host-ui.ts`, `host-approval.ts`, `host-session.ts`, `dsh-host.ts`) load as before, and `get` reads a service another row of the same runtime provides whenever it starts. A host that put one of those names in the adapter row's own `inject` must take it out (a name in a row's `inject` and its plugin's `optional` fails the row). - **Optional-service lookups that fail are logged, not swallowed:** in the DSH and pi adapters a `get` that throws (a wrong `optional` declaration) is a warning once per name, and an absent service a debug line once naming what goes missing (images become placeholders, calls run without a DSH agent, dialogs in print mode). The probe's DSH service check reports why a lookup failed (`lookupErrors`, named by `DshReport::problem`), and a failed row-effect dispose on unregister is logged. - **Plugin authors** (TypeScript, Bun, Python, Go): declare every service you `use` in your plugin's `inject` (or your row's), and those you can run without in `optional`, read with `ctx.get`; `yoagent` belongs in `inject`. A plugin that relied on another row declaring `yoagent` now fails with `service yoagent is not available to this plugin, which does not declare it in inject: add 'yoagent' to its inject`. - **Behaviour changes from rutis, accepted:** - A plugin's cleanups and service withdrawals run on one stack, in reverse, one at a time (rutis #1010; Node used to start them all at once): a cleanup that waits for another waits for good, and one registered while unloading does not run. The adapters' cleanups wait on none of each other; the pi adapter's unregistration now runs before its `session_shutdown` handlers, which still wait (up to 5 s) for a just-finished run's `agent_end`. Plain Cordis plugins (the DSH host stand-in, `host-dialogs.ts`) keep Cordis's disposals. - A runtime row is gated by the shared names in the row's own `inject`, and they are leased into it (rutis #1001): a row whose row-level `inject` names a service not yet there now waits (`waiting for (no row provides it)`, as `rows::wait_running` reports) instead of starting with that `use` failing (Node: reading as running while it never reached `apply`), and stops when its provider goes; the probe and `wait_running` are unchanged. - A runtime that starts but never greets fails its mount after 30 s (rutis #1014; rutis-host's `SpawnPlugin` already had that limit); frames still queued when a session ends are dropped with it, and `SessionOptions::outbound_limit` (off by default, not set by this crate) can bound a slow far end's backlog (rutis #1005). - **Unchanged:** a missing Python hook is still detected by its `AttributeError`; a handler registered from outside any row is still owned by its session (logged at info) — on 0.20 that is a runtime without row scopes, since a row's plugin can no longer reach `yoagent` undeclared. The rutis-agent example moves to rutis-agent at tag v0.20.0 (still unpublished; `[patch.crates-io] rutis` at the same tag). ## 0.26.4 (2026-11-02) Patch release: new model presets and prices, no API change. Released with **yoagent-rutis 0.11.0** (rutis 0.19.3, below); yoagent-workers (0.3.0) is unchanged. - **Three new model presets and five new priced models** (after `prices::enable_bundled()`), each read from the vendor's own page on 2026-11-01: - `gpt_6_1_terra()` — GPT-6.1 Terra on the Responses API (tool calling requires it), 1,050,000-token context, 64K of 128K output, effort ceiling `max`; $1 / $5, cached $0.05, cache write $1.25, and above 272K prompt tokens $2 / $7.50 / $0.10 / $2.50. - `glm_5_3_flashx()` — GLM-5.3-FlashX, the faster GLM-5.3-Flash (thinking always on, 1M / 128K): $0.37 / $1.25, cached $0.075. - `qwen_3_8_omni_flash()` — Qwen3.8-Omni-Flash (an omni model; yoagent sends it text and images; 1M / 131,072; JSON Schema structured output unsupported): $0.15 / $0.47, cached $0.016, international deployment. Alibaba documents it on the workspace endpoint; that the `dashscope-intl` base URL serves it is unconfirmed — set `base_url` if it does not. - `ModelConfig::meta` prices `muse-spark-1.3` (Muse Spark 1.2's rates) and `muse-wonder-1.0` ($4 / $20, cached $0.20, cache writes at the input rate; in preview). The `-contributor` ids stay unpriced. - **Tests:** the 151 mutants that survived the 2026-10-26 mutation run are triaged — 92 caught by new tests, 5 already caught since #393, 3 gone with the code they mutated and 51 documented as equivalent (#198, #403); a flaky MCP stdio test waits for the settled exit answer (#404). ## yoagent-rutis 0.11.0 (2026-11-02) yoagent-rutis only; requires rutis 0.19.3 or a later 0.19, yoagent 0.26 and Rust 1.88. Breaking: rutis 0.19 (its types are in this crate's API), runtimes the host starts itself, and `rows::wait_running`'s signature. - **rutis 0.19.3** (from 0.12). `rutis`, `rutis-bridge` and `rutis-loader` 0.19.3, plus `rutis-host` 0.19.3 (for the `probe` feature); the runtimes move with them: npm `@arcships/rutis`, `@arcships/rutis-runtime`, `@arcships/rutis-bun` 0.19.3, PyPI `rutis` 0.19.3 (**Python 3.12+**, was 3.10+), Go module `github.com/arcships/rutis/go/rutis` v0.19.3 (Go 1.24+). rutis 0.13 and 0.19 changed the protocol: a 0.12 runtime and a 0.19 host refuse each other at the handshake. Not 0.19.0–0.19.2: they named rutis's own services `loader` / `sessions`, which clash with the DSH `sessions` service the DSH host injects (0.19.3 renamed them `rutis/loader` / `rutis/sessions`). Rust plugins follow rutis 0.17's API (`rutis::host::*`, `rutis::Error` / `ErrorKind`, the typed `Plugin` with `type Deps = Arc` to wait for the bridge; `rutis-migrate` 0.18 automates most of it). - **The language features no longer start runtimes.** rutis-bridge 0.19 carries no language: `node`, `python`, `bun` and `go` each enable only the bridge's `yoagent` host service and `restrict_runtime` (`websocket` also rutis-bridge's WebSocket transport). A host starts each runtime itself with rutis-host's `SpawnPlugin` and resolves rows on them with rutis-loader's `SessionResolver`. Before (0.12): ```rust let node = LocalRuntime::node(&runtime_dir, package_json); let rows = Arc::new(RuntimeResolver::node(node.handle()).with_catalog(&catalog)); root.plugin(node); let loader = LoaderPlugin::new(Chain::new().with_shared(rows.clone()), options); root.plugin(loader).await?; root.plugin(RuntimeRowsPlugin::new(rows)); ``` After (0.19; add `rutis-host = "0.19.3"`): ```rust root.provide(LocalId(PeerId::new("main")?))?; // the id the host greets its runtimes with let node = SpawnPlugin::new("node", languages::node::launcher(&runtime_dir), package_json); let runtime = node.handle(); let sessions = Arc::new(SessionResolver::npm(&catalog, "node")); sessions.runtime(node.handle()); root.plugin(node); let loader = LoaderPlugin::new(Chain::new().with_shared(sessions.clone()), options); sessions.attach(&loader.handle()); root.plugin(loader).await?; sessions.follow_row("node"); runtime.ready().await.ok_or("the Node runtime did not start")?; // `reconcile` no longer waits for it ``` Python: `SpawnPlugin::new("py", languages::python::launcher(&modules), &modules).interpreter(&python)`; Bun: `languages::bun::launcher(bun, package, project)`; Go binaries: rutis-loader's `BinaryResolver::` / `BinaryRuntimes::` with a closure that builds each runtime's `SpawnPlugin`. `restrict_runtime` takes the `SpawnPlugin`'s handle. A remote runtime is a session over rutis-bridge's `websocket::dial` (or a `rutis-host/dial` row). `ServiceCatalog::share_registered_only()` is gone; `register_shared("yoagent")` as before. The probe does all of this itself and now pulls rutis-host (with its WebSocket transport and rustls). - **`rows::wait_running(loader, ids, timeout)`** (was `(loader, ctx, layers, ids, timeout)`). A waiting row's `missing` is the services it waits for that no row provides (`WaitReason::NoProvider` / `NoEnabledProvider`), the new `waits` every service it waits for with rutis-loader's reason (`Loader::waits`), and its `note` the loader's own line on why each is not there (`waiting for db (no row provides it)`, `waiting for runtime node (starting)` — what `rutis-host status` prints); `Display` prints the note. A wait that can never end on its own — a provider that failed or is disabled, a cycle, or a service no row provides while no row is loading or may still load — ends the call after half a second instead of at the timeout (provide the host's own services before waiting). The final `Loader::check` (bounded at 10 s) and the per-name lookup are gone. - **Agent events no longer go on the rutis bus** (security; breaking). `AgentEventEmitted` was a serde rutis event (`yoagent/agent_event`) published with `ctx.emit`, and rutis 0.19 lets any runtime session listen to any bus name but `internal/…` (`global` past every filter): any language plugin — a restricted runtime's included — could `ctx.on('yoagent/agent_event', …)` and receive every run's prompts, answers, tool calls and results, past its handler's `options.events` / `options.messages` opt-in, and could `ctx.emit` fake events to every Rust observer. The bridge now keeps the `on_agent_event` observers itself (same method, same `AgentEventEmitted`, removed when their plugin unloads): each gets its own queue and task, one event at a time in the order produced, so a slow one never slows the agent; a panic is logged at `error!`. `AgentEventEmitted` is no longer a `rutis::Event` nor `Deserialize` (still `Serialize`), so `ctx.on::` no longer compiles — use `on_agent_event`. Language handlers get events only through `options.events`, as before; a restricted one never does. - **A restricted runtime's plugins are restricted from their first call** (security). The bridge judged a `register` restricted by the caller's session against the restricted runtime's `Ready` process; rutis-host mounts a runtime's session — whose rows then load and run — before its handle reads `Ready`, so a plugin registering in that window was registered unrestricted. The bridge now also resolves the process the runtime's session provides (rutis-bridge's `session_key()`, what rutis-loader loads its rows through), whatever the handle reads. - **Behaviour changes from rutis, accepted:** - A row's configuration is checked against its plugin's `Schema` before `apply` (rutis 0.18): a mistyped pi adapter config (`exec`, `projectTrustedFor`) fails the row with `bad-config` (`config: exec should be …`) before the adapter's own checks. - A row cannot emit during its own cleanup (rutis 0.13+ withdraws it from the bus first). The DSH host stand-in closed its open DSH turn on unload through DSH's `session/event`, so the session log ended at `turn/start`; it now writes the latest `turn/end` — the one it closes, or one a run's `finish` closed just before the shutdown — through a write handle of its own once the generation's has drained, unless the log has it already — and only an event DSH's session holds: a `turn/end` the session refused (invalid or re-entrant) is not written past it. - A message over 16 MiB fails only that call (`bad-value`, rutis 0.17.2), not the whole runtime; an unhandled rejection in a plugin fails its row (rutis 0.18), not the process. The bridge's image stripping and the adapters' 12 MiB image budget stay: they degrade a call rather than fail it. - **Cleanups the new rutis allows:** the `yoagent` service refuses bad `register` arguments with rutis's `bad-argument` code and an unknown method with `no-method` (was `bad-value`; messages unchanged), so a plugin can tell them apart by `error.code`; the host reads as closed from `Ctx::is_shutting_down()` and the generation token instead of the root fiber's state; the probe builds its root with `Ctx::root_with_sink`, so rutis's reports about its rows go to `tracing` (warn, target `yoagent_rutis::rutis`) instead of stderr; the bridge's own cleanups use the stable `Ctx::effect` (rutis 0.17 left `effect_named` in its unstable `internal` module). The rutis-agent example moves to rutis-agent at tag v0.19.3 (still unpublished; `[patch.crates-io] rutis` at the same tag). - **The probe prints rutis's reports on stderr** (`examples/ecosystem_probe.rs`): with the root's `ErrorSink` sent to `tracing` (target `probe::RUTIS_TARGET`, now public), the shipped probe's only subscriber (`Logs`) dropped them, so reports that reached stderr before were lost; they print as `rutis: …` lines, verdicts unchanged. A row waiting for a service no row declares is settled as `waiting` only while no row is loading or may still load (a row may provide a service it does not declare), and after the rows have read settled for half a second. - **A handler member the session fails to read** (a closed session, a transport error) refuses the `register` with the session's own error and code, not `bad-argument`; a read the plugin's side refused stays `bad-argument`. - **Unchanged:** a missing Python hook is still detected by its `AttributeError` (rutis 0.19 reads a property with a plain `getattr`, which carries no `no-method` code); the probe's DSH service check (`probe-services.ts`) still reads services inside the runtime, which `Loader::waits` cannot see. ## 0.26.3 (2026-10-30) Patch release: a Gemini/Vertex streaming fix, no API change. yoagent-rutis (0.10.0) and yoagent-workers (0.3.0) are unchanged. - **Gemini and Vertex preserve split UTF-8.** Chinese, emoji, thinking text and function-call arguments remain intact when an HTTP chunk ends inside a character; the shared SSE parser buffers bytes until a complete event is available (#391, #392 — thanks @jihtsan). ## 0.26.2 (2026-10-30) Patch release: a compaction fix, a price correction, maintenance and a docs review; no API change. yoagent-rutis (0.10.0) and yoagent-workers (0.3.0) are unchanged. - **`mistral_large_4` repriced to $0.70 / $2.10 per million tokens, cached $0.07** (was $1.36 / $4.18 / $0.14). Mistral's model page and models.dev both list the new rate, with no sale marking; the release price audit caught the drift. The pricing docs no longer describe Mistral's launch sale as running (it ended about 2026-10-20). - **Dependency floor: `rand` 0.10.1** (was 0.10.0), past RUSTSEC-2026-0097. yoagent only calls `rand::random` (retry jitter), so it was not affected; the floor keeps a fresh resolve off the unsound release. - **Docs:** a full review of every page against the code (#398) — errors fixed (among them: `prompt()` during a run waits rather than panicking; `DecisionError::Refused` ends a fallback chain; the `extensions` spend bucket; a non-compiling `OpenAiCompat` example; Bedrock has no default base URL) and gaps filled (0.26.1's URL redaction in the MCP guide, `ModelConfig::preset` / `generic`, the extension builders in the API reference, the synchronous `InputFilter`); `llms.txt` lists every page. - **CI:** every `actions/*` step on its current major, off the deprecated Node 20 runtime (#397). - **Compaction keeps the task prompt and the latest ask** (#150). When the `keep_first` head and the `keep_recent` tail alone exceed the compaction target (a small budget, bulky tool results), level 3 kept only the newest messages that fit the target — dropping the task prompt first, and leaving only the `[Context compacted]` marker when the newest turn alone exceeded the target (#150's `22 msgs -> 1 msg`). It now keeps, in order, the head, the latest user message a person wrote, the newest turn and as much of the rest of the tail as fits — overshooting the target, up to the budget, rather than cutting into them; past the budget the head goes first, then the ask, and the newest turn is cut last (a turn bigger than the budget leaves the marker and the ask). A cut past the latest user message on the normal path keeps it too, right after the marker. The kept head also never ends partway through a multi-call turn's results, which could leave a tool call unanswered, and the guaranteed tail is never less than the newest turn: with a small `keep_recent` the cut could drop the turn whose results the model was waiting on. In the offline sweep (`docs/evals/compaction-defaults.md`) task-prompt loss goes from 100% to 0% at a 26K budget, with no marker-only compactions; tool-heavy sessions compact ~14% more often there (they now keep more). At the default 96K the only change is that tool-heavy sessions no longer lose the latest ask (2% of compactions before). ## 0.26.1 (2026-10-30) Patch release: security fixes, no API change. yoagent-rutis (0.10.0) and yoagent-workers (0.3.0) are unchanged. - **MCP over HTTP: the configured URL stays out of error text** (asked for by yobuild; a security fix). An unreachable server's error carried the HTTP client's ` for url ()`, so a key in the URL (`https://host/mcp?api_key=…`) reached the error — and, from a tool call in a run, the model and the logs as a failed tool result. Errors now name a server by scheme, host and port only — calls, notifications and the connect handshake (`HTTP error reaching https://host: …`), a refused redirect (both ends), the session-teardown warning — with no path (some services put a key there), query or userinfo; a body read failing carries no URL. A server's own body quoted into an error (`Cannot POST /mcp/`, an echoed `api_key=…`), and a JSON-RPC error's message, have the URL's key-like parts — any path segment, query pair or value, user name or password of 8+ characters, and the whole path or query holding one — replaced with `[redacted]` first (a plain `/mcp` stays readable), and session ids are no longer logged. - **MCP over HTTP: a 404 on a notification clears an expired session**, as one on a call does (`McpError::Http { status: 404 }`, "MCP session expired …"), instead of keeping the dead id for the next request to fail on. ## yoagent-rutis 0.10.0 (2026-10-29) yoagent-rutis only; requires yoagent 0.26 (was 0.25), rutis 0.12 and Rust 1.88. No code change: yoagent's types are in its API, so a yoagent minor bump is a minor bump here — this is the release that lets a host take yoagent 0.26 with it. Released with **yoagent-workers 0.3.0**, likewise a requirement bump to yoagent 0.26 and nothing else. ## 0.26.0 (2026-10-29) **Breaking (one change):** `McpError` is now `#[non_exhaustive]` and has a new variant — a `match` over it needs a wildcard arm. Ships with yoagent-rutis 0.10.0 and yoagent-workers 0.3.0 (requirement bumps only, above). - **MCP over HTTP: a typed HTTP status on errors** (asked for by yobuild). A non-success HTTP response is now `McpError::Http { status, message }` instead of `McpError::Transport(text)` — on calls, notifications (the connect handshake's included) and an expired session (404) — so a caller matches on the code (401/403: it wants a credential or a sign-in) instead of the text; `McpError::http_status() -> Option`. The variant is `#[non_exhaustive]` too (match `Http { status, .. }`; a custom transport builds one with `McpError::http(status, message)`), so fields can be added later. Typed where `McpError` reaches you — `McpClient`, `connect_*`, `Agent::with_mcp_server_http*`; a tool call the agent loop runs still reports the text to the model as a failed tool result. The displayed text is unchanged (`Transport error: HTTP 401 Unauthorized from server on '…': …`). - **MCP over HTTP: redirects stay within the server's origin by default** (asked for by yobuild; a security fix). `HttpTransport` followed any redirect (up to 10): the HTTP client drops `Authorization` and `Cookie` on a hop to another host, but a custom header set with `with_header` (`x-api-key`, say) went wherever the server pointed, and nothing stopped `https` → `http`. It now follows redirects within the URL's own origin (scheme, host, port; up to 10) — a 307/308 from `/mcp` to `/mcp/` still works — and a redirect anywhere else is an error naming where it pointed. `HttpTransport::with_redirects(Redirects::None | SameOrigin | Any)` chooses; `Any` is the old behaviour. Native targets; on wasm32 the host's `fetch` follows redirects itself. - **MCP stdio, own process group: the group goes when the command exits** (asked for by yobuild). With `own_process_group`, a command that exits on its own (an `npx` or launcher wrapper) now has its group killed as soon as its exit is seen — by the exit watch (`exited()`, an `McpToolSource` turn) or a call finding its output closed — before it is collected, while its id is still safe to signal. Before, a poll that collected it left what it started running for good. A launcher that backgrounds the real server and exits takes that server down with it (yoagent already reports it gone then). ## 0.25.16 (2026-10-28) Additive. - **MCP stdio: a process group of its own, as an option** (asked for by yobuild). `StdioOptions::new().own_process_group(true)` with `StdioTransport::with_options` / `McpClient::connect_stdio_with` (Unix): a Ctrl+C in the host's terminal no longer reaches the server, and `close()` and dropping the client kill the whole group — what the server started (`npx` → `node`, `uvx` → `python`) goes with it, where by default only the command is killed and a child that ignores its closed input lives on. The costs: when the host exits without dropping its clients (killed, an uncaught Ctrl+C, `process::exit`, an abort), whatever in the group ignores its closed input keeps running — catch Ctrl+C and drop or close the clients; and the group is killed only while the command itself is still the transport's — running, or exited and not yet collected (`waitid` with `WNOWAIT` asks without collecting), so its id is safe to signal; a wrapper already collected (by a call or `exited()`) leaves what it started. Default unchanged. - **MCP stdio: a server that no longer reads its input reads as gone.** A failed write to it marks it, so `McpClient::exited()` answers "… no longer reads its input (its process is still running)" and an `McpToolSource` withdraws its tools. - Test fix: `a_busy_child_repeats_the_provisional_answer` waited only for the exit, not its stderr tail, and failed on Linux when the exit was seen first. ## yoagent-rutis 0.9.0 (2026-10-28) yoagent-rutis only; requires yoagent 0.25, rutis 0.12 and Rust 1.88, as 0.8.3. Additive (minor: new API). - **Restricted runtimes: tools only** (asked for by yobuild, for plugins found on npm and allowed for one session). `RutisBridge::restrict_runtime(handle)` marks a runtime before it starts; every handler registered over its connections (from any row, after a restart — matched by the caller's session against the runtime's running process) gets only `tools` and its tool calls, `before_tool` for calls to its own tools — called inside the tool when it runs, after every policy, under its own name, with the call alone (no request text, no conversation), so it never sees a call of another tool, one sharing a name included; not a policy for `require_policy` — and `finish`; never `after_tool`, `before_model`, `on_input`, `on_stop`, `on_event` or the conversation. Its tools are offered as `__` after every trusted handler's; a failing `tools` offers nothing and never fails a run; a trusted registration evicts a restricted handler holding its handler name; cancelling the run ends its check. `HandlerInfo::restricted()`; `RutisExtension::with_restricted_tools(|handler, tool| …)` names or leaves out their tools (the plugin is called with its own name; `ToolOwner::plugin_name()`). - **Restricted handlers registered mid-run join the run.** `RutisExtension::late_tools()` is a yoagent `ToolSource`, installed on the same agent: a restricted handler that registers while one of the extension's own runs is going is offered from the run's next turn (named like the others), gets `before_tool` for its own tools and `finish`, and `offered_by` names it; a late tool never takes a name the run's requests already offered (the agent's, its sources', any extension's). One extension per agent; with two own runs at once, it waits for the next run. Other handlers still wait for the next run. ## 0.25.15 (2026-10-26) Additive. - **MCP: a stdio server that exits is noticed** (asked for by yobuild). `McpClient::exited() -> Option`: `None` while the server runs; once it has gone, why — its exit status (or the signal that killed it) and the last of its stderr; a server whose output closed while a wrapper process lives on, or that cannot be waited on, reads as gone too; one stopped by `close()` reads as closed, not as a crash. It never waits behind a call in flight (the transport hands the client an `ExitWatch` when it is built, read outside the lock a call holds), so a host can poll it — to tell its user a server stopped between calls, or to restart it. `McpToolSource` uses it: a server that exits is seen at the next turn and its tools are withdrawn (logged once, with the server's name and prefix), so the model stops calling a dead server — a later call to one is answered as an unknown tool. New provided `McpTransport::exit_watch() -> Option` (default `None`; the stdio transport watches its child process, holding it weakly so dropping the client still kills it; HTTP has no process to watch) and `mcp::ExitWatch::new(check)` for custom transports. `Agent::with_mcp_server_stdio` keeps listing once and drops its client handle: to get either, connect an `McpClient` and install it with `McpToolSource`. No restart, no new event: restarting is the host's job. ## yoagent-rutis 0.8.3 (2026-10-26) yoagent-rutis only; requires yoagent 0.25, rutis 0.12 and Rust 1.88, as 0.8.2. A fix; no API change. - **pi: a run's `agent_end` survives the plugins unloading right after the run** (#374). The bridge delivers `agentEnd` after the run's `finish`, so it could arrive after unloading began; the pi adapter, already closed, dropped it, and a tracer saw `session_shutdown` without the run's end. Now: - **Bridge:** a run's `AgentEnd` goes to every handler the run's end reached — its plugin was loaded when the run's `finish` began and, with a `finish` hook, when that hook was called (even if the call failed or the plugin unloaded during it) — even if the plugin has begun unloading since; a plugin unloaded before that gets nothing, as before. A delivery that fails because the plugin has gone since is logged at debug, not as the handler's failure. - **pi adapter:** unloading waits — within the same `OBSERVER_DRAIN_MS` bound (5 s) — for the `agent_end` of every run whose `finish` reached it before unloading began, then for the observer queue, before `session_shutdown`. A run whose `agent_end` has not come 5 s after its `finish` is presumed lost and logged at info, so it neither leaks nor holds up a later unload. The timeout warning names the runs and what was still queued or dropped; events not taken while unloading are reported once (info when an `agent_end` is among them). - Not covered: a run whose `finish` and the unload cross so that the adapter takes the `finish` only after unloading began; its `agent_end` is not delivered to observers and is logged at info. ## 0.25.14 (2026-10-25) - **Tool sources can change mid-run** (asked for by yobuild, for plugins loaded during a run). `ToolSource` gains `generation() -> u64` (default `0`, so existing sources are unchanged): a number that moves whenever the source's tools change. Before each model request after the first — once every tool call of the last turn has returned, so no call is left without its tool — the loop asks again only the sources whose generation moved; their new tools (a shorter list too) are offered from that request, and the collision rules (static tools, then earlier sources, then extensions' per-run tools) are applied again. Extensions' per-run tools are not asked again. Each change rewrites the prompt cache once. Sub-agents' sources work the same way within a delegation. - **MCP: `notifications/tools/list_changed`.** New `mcp::McpToolSource` (`McpToolSource::new(client).with_prefix(..)`, installed with `with_tool_source`): an MCP server's tools listed per run and again mid-run whenever the server announces a new list — a server that loads tools on demand works within one run. The stdio and HTTP transports count the notification while a call is answered (`McpClient::tools_list_changes()`). A custom `McpTransport` opts in through the provided `report_notifications(ServerNotifications) -> bool` (record each notification's method on the handle, return `true`); by default it returns `false`, `McpClient::reports_notifications()` says so, and `McpToolSource::new` logs a warning: over stdio one sent while idle is counted at the next call; over HTTP only one on a call's SSE response is (no standalone `GET` stream). A listing that fails or times out keeps the last list that succeeded. `with_mcp_server_*` keep listing once. ## 0.25.13 (2026-10-23) Two provider fixes; no API change. - **Bedrock, Gemini and Vertex no longer send an empty message** (#369). An assistant turn that errored, was refused or was cancelled with no content (the loop's error message is one empty text block, which the builders filter) — or whose only content is thinking the provider does not replay (on Gemini and Vertex any thinking; on Bedrock unsigned reasoning to a Claude model, or another API's) — went out as `content: []` / `parts: []`, which both APIs reject (as did a user message with only empty text); the Anthropic provider already skipped such an assistant turn. So the prompt after a failed or cancelled run was rejected too. Empty messages of either role are now dropped before consecutive same-role messages are merged (#368), so the user turns around a dropped one go back as one. - **Bedrock, Gemini and Vertex: a parallel tool call's results go back in one message** (#223). Each tool result was sent as its own user-role message, and a steering message, stop marker or compaction summary after them added another, while Bedrock's Converse requires roles to alternate and Gemini requires a turn's function responses in one content, as many as its function calls. So the request after any turn with two or more tool calls (the default parallel strategy) could be rejected. The three request builders now merge consecutive same-role messages, keeping every block in order: the results in call order, then whatever followed; on Gemini and Vertex a merged turn's function responses come first, then any images and text (images among the responses are a reported cause of Gemini's response-count 400). Pinned by request-body tests; not run against a live endpoint. ## yoagent-rutis 0.8.2 (2026-10-23) yoagent-rutis only; requires yoagent 0.25, rutis 0.12 and Rust 1.88, as 0.8.1. Additive. - **`adapters::files()`** (feature `embedded-adapters`): the pi and DSH adapter files built into the binary — `(path relative to plugins/, contents)`, every file under `plugins/pi` and `plugins/dsh` but the test fixtures, with each folder's `package.json` / `package-lock.json`, plus `yoagent.d.ts` — so a host installed without this crate's source (`cargo install`, a release binary) can write them under one folder and run `npm ci` in its `pi/` and `dsh/` (key the folder by version). About 370 KB, opt-in. A test keeps the list equal to the files in `pi/` and `dsh/`, subfolders included; another loads a pi extension through a written-out copy. Asked for by yobuild (M4). - **DSH host: every turn ending is tested** (#363). The stored JSONL events are checked for user cancellation, failed runs, overlapping runs, session resets and host unload, including the failing extension's error and a previous run's finish while another turn is open. ## 0.25.12 (2026-10-21) Additive: one new function. - **`provider::api_key_variables()`**: every environment variable yoagent reads a credential from — each provider's key variables as `resolve_api_key` reads them (Bedrock's `AWS_*` included), the `YOAGENT_API_KEY` / `API_KEY` fallback, and the decision presets' keys (`TYPESAFE_API_KEY`, `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_AUTH_TOKEN`) — for hosts that blank credentials in processes they start (plugin runtimes, MCP servers). `resolve_api_key` now reads the same per-provider table, so a provider's variables cannot drift from the list (Bedrock's and the decision presets' names are pinned by tests). Additive. Asked for by yobuild. ## yoagent-rutis 0.8.1 (2026-10-21) yoagent-rutis only; requires yoagent 0.25, rutis 0.12 and Rust 1.88, as 0.8.0. Additive. - **`probe::pi_with` / `probe::dsh_with` and `ProbeOptions`**: the probe's Node runtime can be given environment variables (`ProbeOptions::default().env(name, value)`, `OsString`s, an empty value included), set over the inherited environment; the DSH probe's private `DSH_HOME` / `DSH_AGENTS_HOME` are set after them and win. `probe::pi` / `dsh` are unchanged. For hosts that blank provider keys in every plugin runtime (yobuild's `check`, whose probes were the one runtime still inheriting them). Asked for by yobuild. ## yoagent-rutis 0.8.0 (2026-10-20) yoagent-rutis only; requires yoagent 0.25, rutis 0.12 and Rust 1.88, as 0.7.1. A minor bump: new values and settings for the pi adapter's grants. - **pi adapter: per-extension grants a host can ask about.** Asked for by yobuild (its M3b plugin grants). - `exec` / `execFor[file]` take `"gate"`: each `pi.exec` call goes to the host's `host_approval.judge([command, ...args], cwd?)` (`options.cwd` resolved against the adapter's `cwd` — and run there, so what runs is what was judged; left out without one, and the call then runs in the adapter's `cwd`). `{free: true}` runs it; otherwise `host_approval.ask(question, details | null, file)` with the judge's question and details, and only `"allow"` runs it. Any other answer, a host error, a cancel or no service is the usual refusal (code 126, reason in `stderr`, warned once per extension, command and kind of refusal; a cancel only at debug). What runs is exactly what was judged: the command, arguments and directory are copied when the call is made (an extension changing its array while you are asked changes nothing), and an `options.cwd` that is not a path string is refused unjudged. `judge` is bounded at 30 s; the question is the host's to time out, and a cancel does not withdraw it (an answer after the cancel is only logged). - `projectTrustedFor: {file: boolean}` over `projectTrusted`: what `ctx.isProjectTrusted()` tells that extension's handlers and tools. - `wakeFor: {file: boolean}` (default none): that extension's `pi.sendUserMessage(content)`, and `pi.sendMessage` with `triggerTurn` or `deliverAs`, become `host_session.wake(text, file)` (text blocks joined by line breaks, other blocks named; one at a time in the order sent, each after the previous answered or timed out; the message is still recorded). pi's calls return nothing, so a refusal is a `warn` line (once per extension and kind — no service, timeout, host error — repeats at debug), not an error the extension sees; a wake with no answer in 30 s is reported as such (it may still arrive) and later wakes go on. As in pi, a wake from an extension's factory (while extensions load) throws, and so does one once the adapter starts unloading (`session_shutdown` handlers included). - Keys of every per-file setting resolve like `extensions`; one naming no loaded extension, or two spellings of one file, refuses the load. `ask` and `wake` name the extension's resolved file last (`judge` gets the argv and cwd only). - The services reach the adapter through two new rows, `plugins/pi/host-approval.ts` and `plugins/pi/host-session.ts`, which re-provide `host_approval` / `host_session` as `pi-host-approval` / `pi-host-session` (as `host-ui.ts` does `ui`): the adapter looks them up per call, so rows without these grants load without them. - Fixed-answer log lines name the asking extension: `[pi] fixed answer (): → `, once per extension, question and answer. ## 0.25.11 (2026-10-20) A fix; no API change. - **A tool result's line budget covers all its text blocks.** `tool_output_max_lines` was applied to each text block on its own, so since 0.25.9 sent every block (#347; Bedrock, and results with an image, sent them before) a result of N blocks could reach the model as N × the budget — 50 blocks from an MCP server, 10,000 lines where 200 were meant. The budget is now the result's: blocks no longer than an even share stay whole, the others split what is left (the remainder to the earliest), each cut head + tail with its own marker and stash key as before. A block not left whole keeps at least 5 lines (head, marker, tail; a shorter one stays whole), so when the even share would be under 5 lines — more text blocks than a fifth of the budget — the result goes over the budget by up to 5 lines per such block, never cut without a marker. Below a budget of 5 there is no room for a marker and nothing is shared: each block keeps the budget, as before. One-block results are unchanged, and truncation stays idempotent, so history written by this version is never rewritten. A multi-block result stored by an earlier version that is over the budget in total is cut once more, the first time compaction's level 1 runs over it: one prefix-cache rewrite. A stash key its old markers name may be cut out of the context then (the stash entry itself is kept), and that cut's own marker names no key: compaction does not stash. ## yoagent-rutis 0.7.1 (2026-10-20) yoagent-rutis only; requires yoagent 0.25, rutis 0.12 and Rust 1.88, as 0.7.0. Additive: new pi adapter settings `model`, `models` and `execFor`, pi observer events, the opt-in DSH host and `adapters::dsh_host_rows`. - **pi adapter: models and per-extension `exec` grants.** `ctx.model` and `ctx.modelRegistry` were always `undefined`, so extensions that read them threw (pi-background-tasks in `session_start`). New settings `model` and `models` (`{provider, id, name?, contextWindow?, maxTokens?, reasoning?, input?}`) give them in pi's `Model` shape, without `model` the model id of the run's latest request under provider `yoagent` (`undefined` before the first request, so an extension that needs a model in `session_start` — pi-background-tasks — needs the host to set `model`); the registry is read-only (`getAll`, `getAvailable`, `find`, the `*OfType` lookups, a no-op `refresh`, …) — credentials, model calls and provider registration throw "not available in yoagent". New setting `execFor` grants `pi.exec` per extension file over the row-wide `exec` (a key naming no loaded extension refuses the load). Asked for by yobuild. - **DSH host fixes** (after #356): runs a sub-agent delegates to at any depth join the top-level run's DSH turn (a grandchild under a child that made no DSH call opened a turn of its own and closed the top-level run's — every run that carries the bridge, as its own extension or a parent's tree extension, now tells the host who delegated it, from the adapter's `tools` and `before_model` hooks); a failed run's turn ends with the run's error (and the extension that failed it), not a generic message; approvals ask "Allow once" / "Deny" instead of a confirm, since a host's confirm answers `false` for no answer too and DSH recorded it as a rejection — no answer is now `unavailable`; a cancelled call stops waiting for its stand-in while queued; the log flush after a turn no longer holds up `finish` (which yoagent bounds at 5 s); `runMaintenance` is documented as running its task at once. Every step of the stand-in's queue is bounded (a timeout names the step), a generation's late flush is skipped once it is torn down, and a host answering an approval with neither option is logged. Tests read the session logs back from disk (turns and the approvals' audit pairs), check each approval outcome by its text — with the scripted UI answering an unanswered confirm `false`, as yobuild does — and that a grandchild's call reached the host. - **DSH host: a stand-in DSH agent for the adapter's calls** (`plugins/dsh/dsh-host.ts`, rows in `plugins/dsh/dsh-host.json`, opt-in). Many DSH plugins expect the call's DSH agent — its workspace, DSH's approval service, agent-scoped questions — and failed on the adapter's agentless calls (dsh-univer-office: `SESSION_SCOPE_UNAVAILABLE` on every file tool). With the rows from the new `adapters::dsh_host_rows(dsh, sessions)` loaded (`host-services.json` with DSH's `dsh-session`, `dsh-agent`, `dsh-user-approval` and the jsonl session log in place of its storage-less persistence row — rutis-loader keeps the first of two rows with one id, so the rows must be merged, not appended) and the host sharing `host_session` (`info()` → `{id, generation}`, `wake(text)`) and `host_workspace` (`cwd()`, absolute), each call carries a stand-in agent: one per host session generation (a reset starts a new one; its id a fresh `yoagent-`, since DSH's default ids restart per process and the log refuses one it holds), working in the host's workspace; each top-level run is one DSH turn (its sub-agents join it; ended `completed`, or `aborted` for a cancelled run, `error` for a failed one); steering from a plugin goes to the host as text (a refusal logged); its session events are written by DSH's jsonl backend. Host calls are bounded (30 s) and follow the tool call's cancel. `host-dialogs.ts` now also answers DSH's `approval/request` (with the call's arguments; no answer is `unavailable`, not a rejection) and the stand-in's questions through the host's `ui`. Calls without the host loaded stay agentless, as before. Tested with a fixture and, opt-in (`--ignored`, installs from npm), with dsh-univer-office 0.3.7 end to end (yobuild's 2026-10-19 probe). DSH stays at 0.2.0-rc.2: published DSH plugins declare `^0.2.0-rc.1`, which npm does not match to 0.2.1 prereleases. Asked for by yobuild. - **pi adapter: observer events fire.** `agent_start` / `agent_end`, `turn_start` / `turn_end`, `message_start` / `message_update` and `tool_execution_start` / `_update` / `_end` handlers were loaded and never called (reported as "not available"), so extensions built on them — per-turn summaries such as pi-lens's, tracers such as langfuse and langsmith — loaded and did nothing. They now fire from yoagent's event stream (the bridge's `on_event`, subscribed only to the event types the handlers present at load need), in order and in pi's shapes: tool calls under pi's names and argument shapes, `turn_end` with its turn index, message and tool results, `message_update` with the streamed delta. They observe only: a handler's result is ignored (a `turn_end` asking to continue or add session entries is reported once), and a throwing handler is reported once while the others still run. Handlers run on the adapter's own ordered queue, so a slow one (a tracer flushing over the network) never meets the bridge's per-event timeout, which would have switched every observer off for the run and failed a required bridge's run; a handler stuck past 30 s is left running while the queue moves on; past 1000 waiting events, streaming events are dropped first (starts and ends only past 2000), counted per burst. Unloading waits up to 5 s for queued events, so `agent_end` comes before `session_shutdown`. `turn_end` fires only for a turn that ended with an assistant message, as in pi. `message_end` stays on the checked path and is not ordered with these events. `turn_end`'s session entry ids are generated and its context preview is empty, since yoagent keeps no pi session. Asked for by yobuild. ## 0.25.10 (2026-10-19) Additive: new API and docs, and two fixes. - **Extensions can record model calls they make into the run** (`RunContext::spend_handle() -> Option`; `SpendHandle::add(&usage, &model)` / `add_priced(&usage, Option<&CostConfig>)`). A host service that makes a model call for a plugin had no way to charge it to the run: a `Budget` never saw it, and neither did the run's stats. Recorded spend is priced by yoagent from the model's `cost` (never a dollar amount), counts against `Budget` at its next check, and lands in the new `SessionStats::extensions` bucket (`ExtensionSpend { usage, cost_usd, requests }`), which `total_usage()` and `total_cost_usd()` include — an unpriced call makes the total `None`, as an unpriced turn does — and which sub-agents' runs fold into. `Agent::extension_spend()` sums it across runs. The handle is cheap to clone and works from any task while the run is live; spend recorded after the run ended is not counted, with a warning. Asked for by yobuild (a metered `llm` host service). - **Steering and follow-ups are no longer lost when a run is cancelled.** The loop takes steering messages from their queue at the start, between tools and after each turn (and follow-ups when the agent would stop), and holds them until the next turn injects them. A cancel landing in between ended the run without injecting them, so they were in neither the history nor their queue: a user's steer sent as the run was being stopped vanished. They are now appended to the history (with their `MessageStart` / `MessageEnd` events) before the cancel marker, so the next run sees them. Reported by yobuild, which had been re-sending what it steered. - **`ToolExecutionStrategy::Batched { size: 0 }` no longer answers a call twice when steered.** Size 0 runs as size 1, but the count of calls already run used the raw 0, so a steer after the first batch gave every call — including the ones that ran — an extra "skipped" result, which providers reject as a duplicate tool result. - **Model presets can be listed and looked up by provider and id**: `ModelConfig::presets() -> impl Iterator` (all 22 named presets; order unspecified), `ModelConfig::preset(provider, id) -> Option` (exact, case-sensitive match on `provider` and `id`, no aliases; builds and prices only the match) and `ModelConfig::generic(provider, id) -> Option` (that provider's generic constructor with `id` as the name, never a preset, for the providers in `PRICED_PROVIDERS`; an OpenAI reasoning id gets the Responses API; `None` for gateways, local servers and custom endpoints). Write `preset(p, id).or_else(|| generic(p, id))`, and tell the user when the fallback ran: its limits are the provider's defaults, not the model's. A host that takes a model as text (`xai/grok-4.7`) had to keep its own list of presets, which drifts silently, and a generic constructor gives a known model wrong limits without a word: `ModelConfig::xai("grok-4.7", ..)` budgets for a 131K window and stops replies at 4,096 tokens where `grok_4_7()` has 500K and 64K, and `ModelConfig::openai("gpt-6-sol", ..)` talks Chat Completions where `gpt_6_sol()` uses the Responses API (found by yobuild). A unit test fails when a model priced in `prices.json` has a preset not listed (models priced without one — DeepSeek's, Meta's — are named in the test), and `tests/preset_prices_test.rs` when the list and the pinned presets differ. - **Docs: each generic constructor says its limits and names the presets for its provider.** `openai`, `openai_responses`, `google`, `xai`, `mistral`, `zai` and `qwen` state their default context window and output limit and point to their presets (and `ModelConfig::preset`); `groq`, `minimax`, `deepseek` and `meta`, which have none, state their limits. `xai`'s doc no longer lists `grok-4.7` as a model for the generic constructor. ## yoagent-rutis 0.7.0 (2026-10-19) yoagent-rutis only; requires yoagent 0.25, rutis 0.12 and Rust 1.88, as 0.6.1. A minor bump: the pi adapter's `pi.exec` is closed by default, which changes behaviour for extensions that call it; new settings `exec` and `projectTrusted`. No Rust API change. - **pi adapter: `pi.exec` is closed by default** (breaking for extensions that use it). A pi extension's `pi.exec(command, args)` ran any command, outside every tool policy, middleware and `ToolGate` the host set. It now runs only what the adapter's new `exec` setting allows: `true` (anything, as before), or a list of commands matched exactly (`"git"` allows `git`, not `/usr/bin/git` or `./git`). Only the command is checked, not its arguments or working directory: allowing a command that can run others (`git`, `sh`, `node`, `npx`) allows those too. A refused call resolves as a failed command (code 126, empty `stdout`, the reason in `stderr`), never a throw, and is logged at warn once per extension and command. This gates pi's API, not the process: an extension is code the host runs and may still start processes itself. To gate it, every load (whatever `exec` is) imports each extension's module through pi's loader by a small wrapper the adapter writes to a per-user directory under the temp directory (`yoagent-pi-wrappers-`, reused; the load fails, saying why, unless it is a real directory owned by that user that no one else can write), its factory is run by pi's `loadExtensionFromFactory`, and `ext.path` is the resolved path. Asked for by yobuild. - **pi adapter: fixed answers are logged and `isProjectTrusted` is configurable.** Each question the adapter answers with a fixed value (`ctx.isProjectTrusted()`, `ctx.model`, `ctx.modelRegistry`, `ctx.getContextUsage()`, a print-mode dialog) is logged at info the first time (once per question and answer in a runtime), as `[pi] fixed answer: → `, so a plugin that does nothing can be traced to what it was told (pi-lens starts no language server in an untrusted project and reports every file clean). The new `projectTrusted` setting (default `false`; pi's own default is `true`) sets the answer. ## 0.25.9 (2026-10-19) - **A tool result's text reaches the model whole.** When a tool result held several text blocks and no image, the Anthropic, OpenAI-compatible, Responses (OpenAI and Azure), Gemini and Vertex providers sent only the first block and dropped the rest (Gemini and Vertex did so with images too). An MCP tool that answers in several blocks was seen as its first line only: ESLint's MCP server (`@eslint/mcp`) answers `lint-files` with an intro line, the findings and a closing note, so the model saw "Here are the results of running ESLint …" and reported the files clean (found by yobuild, through DeepSeek). Every text block is now sent, in order, joined with a newline (`provider::tool_result_text`, crate-private; empty blocks left out, so an empty first block no longer hides the text after it); results with images keep their array form on Anthropic, OpenAI-compatible and Responses, and their `inlineData` parts on Gemini and Vertex. Bedrock already sent every block (now pinned by a test). A single-block result is sent byte for byte as before; a resumed session whose history holds multi-block results rewrites its prompt cache once. The GASP recorder's tool output summary reads every block too (an empty result records `(no output)`). Each block is still truncated on its own (`max_lines` per block), so a tool that answers in many blocks can now put more into context than one block could. ## 0.25.8 (2026-10-19) Additive: new presets and fixes; no API removed. The skill notes the yoagent-rutis 0.5–0.6 releases. - **Eleven new model presets, all priced** (after `prices::enable_bundled()`), with each model's real context window and output limit — the generic constructors assume conservative limits (often 128K context and 4,096 output tokens), so `ModelConfig::xai("grok-4.7", ..)` budgeted for a 131K window and stopped replies at 4K (yologdev/yoagent#332 asked for `gpt-6.1-sol`): `claude_sonnet_5_5()`, `claude_haiku_5_5()` (adaptive thinking; Haiku 5.5 bills a prompt over 100,000 tokens at its second rate), `gpt_6_1_sol()` (Codex's default; Responses API, 272K tier), `gemini_4_argon()`, `gemini_3_8_flash()`, `grok_4_7()` (200K tier), `mistral_large_4()`, `glm_5_3()`, `glm_5_3_flash()`, `qwen_3_8_max()`, `qwen_3_8_flash()`. Google, xAI, Mistral, Z.ai and Qwen models were never priced before. Each rate was read from the vendor's own pricing page on 2026-10-18 (`source` / `verified` in `prices.json`) and cross-checked by the price audit. **Promotions:** `gemini_4_argon` (introductory through 2026-11-30), `gemini_3_8_flash` (through 2026-12-31) and `mistral_large_4` (launch sale to about 2026-10-20) carry the **regular** rate, so their costs read up to twice the bill until the promotion ends — an upper bound, like DeepSeek's peak rate. The price audit compares them as twice models.dev's promotional rate and fails once models.dev lists the regular one. - **Gemini: thinking tokens now count as output.** Google bills them as output but reports them apart (`thoughtsTokenCount`); `Usage::output` (Gemini and Vertex) held only the visible tokens, so the cost of a thinking Gemini model was undercounted wherever a price was set. As with the other providers, the context tracker and `max_total_tokens` now count them too, so a Gemini turn that thinks a lot reaches compaction and the token limit sooner. - **OpenAI-compatible providers: reasoning tokens reported beside `completion_tokens` count as output.** When `total_tokens` equals `prompt_tokens + completion_tokens + completion_tokens_details.reasoning_tokens`, the reasoning tokens are added to `output`; a provider that counts them inside `completion_tokens` (OpenAI) is unchanged. Protects Grok 4.7's cost, whose reasoning is always on. - **Promotional prices under live pricing:** `prices::enable_live*` installs models.dev's current rate over the snapshot, which for the three promotional models is the promotional one — the true bill today, not an upper bound. A live table or cache fetched before a promotion ends keeps that rate until refetched. - **`read_file`, `write_file` and `edit_file` refuse what is not a regular file** (#341): a FIFO, a character or block device, a socket or a directory, after following symlinks, is an error naming what it is (so `write_file` to `/dev/null` or `/dev/tty` is refused too). A FIFO reports a length of 0, so it passed `read_file`'s size check and the read blocked until something wrote to it; `/dev/zero` was read without end. - **`bash` no longer waits for a background job that holds its output** (#342). The call waited for both output pipes to close, and a job started with `&` inherits them, so `sleep 300 & echo hi` held the call until the timeout, which then reported the command as timed out and killed the job. The call now ends when `bash` exits: the output is read for one more second, then a job still holding the pipes is left running — its later output is read and discarded in the background, so it is not killed by a closed pipe — and the result says so (a `NOTE:` line, `details.output_still_open`). - **DeepSeek:** the `deepseek-flash` docs name its current snapshot (`DeepSeek-V4.1-Flash-1017`, which takes images — verified live); the legacy `deepseek-v4-flash-vision-exp` is priced like `deepseek-v4-flash` (DeepSeek serves and bills it as Flash). ## yoagent-rutis 0.6.1 (2026-10-18) yoagent-rutis only; requires yoagent 0.25, rutis 0.12 and Rust 1.88, as 0.6.0. Additive: the `loader` feature and the `rows` module. - **`rows::wait_running`** (feature `loader`): wait until the plugin rows a host loaded with rutis-loader run, at most a timeout. `reconcile` counts a row still waiting as settled, and since rutis 0.12 a row injecting a service nobody provides waits for good, so a host reading only `reconcile`'s failures sees a plugin that never starts as a success. A row that never will (failed, disposed, unresolved, disabled, stopped, no such row) ends the wait at once; rows waiting, loading, unloading or inactive are waited for. At the end each row still waiting is reported (`RowsNotRunning`, a `RowState` per row, `missing` and a `note` saying why it is unknown when it is) with the services it injects that do not exist — from `Loader::check` (bounded at 10 s) plus a lookup in rutis, since `check` alone cannot tell (a 0.12 JavaScript row may provide undeclared services, and a name another row only declares reads as provided). The `dsh_tools` and `pi_extensions` examples use it (they now need `--features node,loader`). Asked for by yobuild. ## yoagent-rutis 0.6.0 (2026-10-18) yoagent-rutis only; requires yoagent 0.25, rutis 0.12 and Rust 1.88. A minor bump: rutis 0.12 (its types are in this crate's API) — move the npm, PyPI and Go runtimes to 0.12.0 with it — and language handlers owned by their rows. - **A language handler belongs to its row** (rutis 0.12's `session::caller_row`, which yoagent-rutis asked for in arcships/rutis#441). A TypeScript, Bun, Python or Go plugin loaded as a row now gets the row's loader id as its owner (`HandlerInfo::owner`, `ToolOwner::owner`, `offered_by`), seen by the host rather than said by the plugin, so a host can trust tools per row. Its registrations are tied to that row: removed when the row unloads or restarts while its runtime runs on, whether or not the plugin handed the disposer to `ctx.effect`. Calls from outside any row keep the runtime session as before; so does a plugin that finds `yoagent` without declaring it in `inject` (logged at info) — declare it to be attributed. - **rutis 0.12** (from 0.11; breaking: rutis types are in this crate's API, so the next release is 0.6.0). `rutis`, `rutis-bridge` and `rutis-loader` 0.12, the npm runtimes (`@arcships/rutis`, `-runtime`, `-bun`) 0.12.0, PyPI `rutis` 0.12.0 and the Go module v0.12.0 move together; Rust 1.88 as before. One code change: `probe` keeps the old sharing rule (below). Two behaviour changes from rutis: services a plugin `ctx.provide`s without declaring them now reach rutis, so on a name clash the row providing it later fails (rename it, or list it in that row's `isolate`; the pi adapter's `host-ui.ts` provides `pi-host-ui`, so load it in one runtime only); and `ServiceCatalog::new()` shares every name by name, so a Cordis row injecting a service nobody provides now waits in rutis instead of reading as started (`share_registered_only()` restores the old rule — `probe` uses it, so a DSH plugin missing an app service is still reported at once). New tests pin the probe's quick report and that `host-ui.ts`'s undeclared `pi-host-ui` provide still brings pi dialogs to the host; the pi and DSH tests now refuse a stale (pre-0.12) runtime install. rutis 0.12 also lets a Rust service see the row that calls it (`session::caller_row`, arcships/rutis#441), which the change above uses. ## yoagent-rutis 0.5.1 (2026-10-18) yoagent-rutis only; requires yoagent 0.25, rutis 0.11 and Rust 1.88, as 0.5.0. **Upgrade from 0.5.0 if you trust pi tools per extension** (`offered_by.source`): its pi adapter could run a different extension's tool under an offered name. No Rust API change; the pi adapter gains the `nestedCalls` config. - **pi adapter: `nestedCalls`** (config: `"any"`, the default and pi's behaviour; `"own-extension"`; `"off"`): which tools a pi tool may call through `ctx.executeTool`. Nested calls are judged by pi's `tool_call` handlers only, never by the host's per-call policies, so a host that trusts tools per extension sets `"own-extension"`: allowing one tool then never reaches another extension's tools through it. A refused call is an error result to the calling tool and an `info` line in the host's log; `ctx.tools` lists only what it may call; an unknown value refuses the load. Asked for by yobuild. - **Fixed: a pi tool name registered mid-run could take over a call.** The pi adapter looked a tool up again by name when a call came in. So an extension earlier in the `extensions` list that registered the same name after the run's tools were offered (from a `tool_call` handler, a timer, any event) ran instead, while `offered_by.source` still named the extension whose tool was offered — a per-extension allow list (yobuild's) let the other extension's code run. Each run now judges and runs exactly the definitions its `tools` offered, nested `ctx.executeTool` calls included, and `ctx.tools` hands out frozen per-run copies (a running tool could swap another tool's `execute` through it). Found by yobuild's security review. Still documented as limits: nested calls are judged by pi's `tool_call` handlers only, not by the host's per-call policies; extensions in one runtime share a process. - **Documented**: since rutis 0.11 (yoagent-rutis 0.5.0), a dsh tool call whose arguments hold an integer beyond ±(2^53−1) is refused by dsh itself ("tool execution arguments must be losslessly JSON-serializable", an error result the model sees), before dsh's `tools/pre-execute` stage (its policies and the approval dialog); yoagent's own policies have already run. With rutis 0.9 the number arrived as a JS number, rounded when a float64 cannot hold it, and the tool ran. Now pinned by `dsh_test`. ## yoagent-rutis 0.5.0 (2026-10-17) yoagent-rutis only; requires yoagent 0.25, rutis 0.11 and Rust 1.88. A minor bump: rutis 0.11 (its types are in this crate's API), and new public API (`Registry::offered_by` / `ToolOwner`, hook timing). - **Which plugin offered a tool**: `Registry::offered_by(run_id, tool)` and `ToolCall::offered_by` (on every `before_tool` / `after_tool` call, also in a language plugin's JSON) give the `ToolOwner` of a plugin tool in a run: handler name, the plugin it registered under, and an optional, self-declared `source` per tool. The pi adapter sets `source` to the extension file that registered the tool (as pi resolved it); TypeScript, Python and Go plugins may set `source` on the tools they return; DSH tools carry none. `None` for the agent's own tools. For a host's policy that scopes trust per plugin (asked for by yobuild's approval gate). A language plugin's owner is its runtime session: rutis does not tell the host which row of a runtime made a call. - **rutis 0.11** (from 0.9; breaking: rutis types are in this crate's API, so the next release is 0.5.0). `rutis`, `rutis-bridge` and `rutis-loader` 0.11, the npm runtimes (`@arcships/rutis`, `-runtime`, `-bun`) 0.11.0, PyPI `rutis` 0.11.0 and the Go module v0.11.0 move together; Rust 1.88 as before. No code change was needed. What 0.10 and 0.11 bring through the bridge: runtimes exit when the host's session ends; leaf plugin events and remote runtimes that connect in (rutis features, not yet used by the bridge); cross-language value types — an integer beyond ±(2^53−1) now crosses as a big integer, which a 0.10-or-older peer refuses, so plugins and runtimes must move to 0.11 together (plugins built with rutis's 0.11 SDK need 0.11 runtimes). The pi adapter compared arguments with `JSON.stringify`, which refuses a big integer, so it would have denied such a call; it now compares them in a form that keeps them. A `log` argument with no JSON form (a JS `Date`) is now written as empty rather than dropped, so the arguments after it keep their places. DSH already uses Cordis 4.0.4, the version rutis 0.11's JS packages now depend on, so one Cordis is kept. - **Hook timing per plugin handler**: `Registry::timings()` and `take_timings()` return a `HookTiming` per handler and hook kind (`tools`, `on_input`, `before_model`, `before_tool`, `after_tool`, `on_stop`, `on_event`, `finish`) and per tool the handler offers (`hook: "tool"`, `tool: Some(name)`): calls, failed calls, cancelled calls (a hook of a cancelled run, a `finish` cut off by yoagent's limit, a tool that returned `ToolError::Cancelled`), total and longest time, as the agent waited for them (measured at the bridge; for a language plugin, the round trip to its runtime, and for its `on_event` the time to queue an event it subscribed to; for a Rust handler, the call itself). Always on: two clock reads and a short lock per call; no change in behaviour. Asked for by yobuild, to decide from numbers whether a plugin should become a Rust part. ## 0.25.7 (2026-10-16) - **`McpToolAdapter::with_prefix` / `from_client_with_prefix` now name the tool** `__` (as `OpenApiConfig::with_name_prefix` does for OpenAPI tools). The prefix was stored but never used, so two servers offering a tool of the same name, or a server tool named like a built-in, still gave the agent duplicate tool names, which a provider rejects. The server is still called with its own tool name. The MCP guide now shows how (the `with_mcp_server_*` helpers add no prefix). Reported by the yobuild session. - Docs: the agent skill's plugin numbers follow the re-measured compatibility matrix (2026-10-16: pi 12 of 20, DSH 3 of 20; no verdict changed, a usable DSH plugin left the npm top 20), and the moved examples' stubs link to yobuild's `examples/`. ## 0.25.6 (2026-10-16) - **DeepSeek is priced** (after `prices::enable_bundled()`): `deepseek-flash`, `deepseek-v4-pro` and the legacy `deepseek-v4-flash` (billed as Flash), from DeepSeek's pricing page. **At the peak rate:** DeepSeek bills half of it off-peak (all hours but 01:00–04:00 and 06:00–10:00 UTC on weekdays), and a `CostConfig` has no time of day, so costs are an upper bound — off-peak they read up to twice the bill, and a `Budget` stops early rather than late. Install the off-peak rates as an override to count those instead (`docs/concepts/pricing.md`). `ModelConfig::deepseek(..)` for those ids now has a `cost`; `Budget::for_model` works for them. The price audit checks the rates as exactly twice models.dev's off-peak ones, and models.dev data (`enable_live`, a fetch or a price cache written by an earlier version) never brings DeepSeek's off-peak rates back over the bundled peak ones. `examples/llm_compaction_live.rs` reads DeepSeek's rates from the snapshot instead of its own literals. ## yoagent-rutis 0.4.0 (2026-10-16) yoagent-rutis only; requires yoagent 0.25, rutis 0.9 and Rust 1.88. A minor bump: new public API (`adapters`, the `probe` feature). - **`yoagent_rutis::adapters`: where the pi and DSH adapters are.** `adapters()` returns the folders of the adapter files the crate ships (`plugins/pi`, `plugins/dsh`, `plugins/yoagent.d.ts`), so a host can copy them and `npm ci` the copy instead of keeping its own copy of them in step with the crate's. - **`yoagent_rutis::probe` (feature `probe`): vet a plugin package without running it as an agent.** `probe::pi` and `probe::dsh` load a pi extension or DSH plugin rows through the adapter, as an agent would, and report what an agent gets — tools, prompt additions, which DSH services it needs and whether they exist, skills, and what the plugin logged — without calling a tool or a model. Both take `plugins`, a copy of `adapters().root` with `npm ci` run in its `pi/` and `dsh/`. Each report's `problem()` is the verdict (`None` = a clean load). Probes in one process run one at a time, so each report carries its own plugin logs. It is what `examples/ecosystem_probe.rs` (the compatibility matrix's probe, now with `--plugins`) ran; the example now calls it, with the same verdicts. The feature adds `rutis-loader`, `tempfile` and `tracing-subscriber`; the default build is unchanged. - **The probe reports what used to read as clean.** - A pi extension the adapter refuses after load (a handler for an event yoagent never fires, or a same-name tool added later) is `loaded: false` with the refusal, where it read as loaded with no tools. A late same-name tool is retried with `withoutBuiltins`, like one at load. - A DSH plugin's injected services are read from its modules inside the runtime, so a missing one is found without the caller listing it (`inject` adds to them). Every key counts: Cordis requires them all, so one declared `required: false` and missing used to read clean (also in `inspect-package.mjs` and the matrix). A module whose `inject` could not be read is a problem, not a clean verdict. - The probe copies its two DSH files (`dsh-tools-adapter.ts`, `probe-services.ts`) into `base` when they are missing: they must resolve the base's Cordis, and a base with only one of them failed every service check. - DSH rows that are not an array of rows with string ids are an error. - A bare `SKILL.md` path is a skill in the current folder (it was dropped). - pi's early failures keep their logs. - Logs include the bridge's reports of failed hooks (`yoagent_rutis::extension`). - A DSH report's rows are only the plugin's (the base's host-service rows were listed too). - On error paths the reports now carry empty fields (`rows: []`, `services: null`) where the old example printed nothing, and an unreadable `host-services.json` is an error, not a panic. ## 0.25.5 (2026-10-16) ### yoagent - **`search` ignores the user's ripgrep config and `GREP_OPTIONS`** ([#315](https://github.com/yologdev/yoagent/issues/315), reported by yoyo). The tool passes `--no-config` to `rg` and clears `RIPGREP_CONFIG_PATH` and `GREP_OPTIONS` for its child. A `RIPGREP_CONFIG_PATH` used to reach every search the agent ran. It could change the `path:line:text` output that the tool and its consumers read (`--no-filename` left a bare `1:…` with no path), and with `--follow` it could walk out of `SearchTool::with_allowed_paths` through a symlink and return a file from outside it. A config's `--pre` would also run a program on every file searched. **On macOS, which ships no ripgrep, the `grep` fallback had the same hole:** BSD grep honours `GREP_OPTIONS` (`-h` drops the paths, `-S` follows symlinks). Your settings still apply to `rg` or `grep` run through `bash`; they no longer apply inside the agent's `search` tool. - **`search` finds `rg` without `which`, and says which engine ran.** It looked `rg` up by running `which`, so on a system without `which` (minimal containers) it fell back to `grep` with ripgrep installed, and said nothing; results differ (`grep` ignores `.gitignore` and reads `a|b` and `\d` differently). It now searches `PATH` itself, and `details.engine` is `"rg"` or `"grep"`. - **A search that ends abnormally is never "No matches".** One killed by a signal, ending with an exit code other than 0, 1 or 2, or whose output could not be read, used to report "No matches found" when it had printed nothing. It is now an error ("the search ended abnormally …"), and matches printed before such an end come with a warning that the results may be incomplete. ### yoagent-frontend moved to yobuild - The experimental frontend (`integrations/yoagent-frontend`: sessions, the browser, phone and terminal UIs, the plugin host) and its `coding_agent`, `handoff_agent` and `minimal_browser` examples moved, with their history, to [yobuild](https://github.com/yologdev/yobuild), and `scripts/try-coding-agent.sh` and `scripts/try-handoff.sh` with them (stubs here say where). yobuild builds them on yoagent and yoagent-rutis from crates.io. It was never published; its unreleased notes went with it. yoagent stays a loop library. ## 0.25.4 (2026-10-14) - **`ToolGate` judges large calls whole, and commands only whole.** Before, every string argument over 2,000 characters was cut to its head and tail, so a 100,000-character `write_file` was judged on 2,000 characters of content and a long `bash` script with `rm -rf` in its middle was judged without that line; a call still over 12,000 characters after that was denied. Now arguments are sent **whole when they fit** `ToolGate::with_max_argument_chars` (default 48,000 characters) and the decision backend's token limits; a call that does not fit has its longest strings cut only as far as needed, and the state lists what was cut under `tool_call.omitted`, and the gate logs such a partial view at `info` (which arguments, how many characters it did not see). Model-judged denials are now logged at `info` too. **Full views** are never cut — judged whole or denied: `with_full_view(tool, json_pointer)`, by default the built-in `bash` tool's `/command` (`without_default_full_views` drops it). **Behaviour change:** a `bash` command too large to review in full (over about 48,000 characters once serialized as JSON, or the decision backend's token limit if that is smaller) is now denied instead of judged from its head and tail; more calls are sent in full, so large calls cost more decision-model input tokens. - Docs: the installation page's snippet pinned `yoagent = "0.24"`; it is `"0.25"` now. The agent skill's "What changed" covers this release. ## yoagent-rutis 0.3.1 (2026-10-14) yoagent-rutis only; no Rust API change (yoagent stays a ≥0.25.1 requirement, rutis 0.9, Rust 1.88). - **DSH's own host services for dsh plugins**: `plugins/dsh/host-services.json` lists the DSH packages that provide what many dsh plugins wait for — files, attachments, credentials, storage, session persistence, skills — and run headless; the compatibility probe loads them, the frontend examples on request (`--dsh-services`). Attachments, the credentials file and skills live under `DSH_HOME` (else `~/.dsh`) and skills also under `DSH_AGENTS_HOME` (else `~/.agents`), so hosts give the runtime homes of their own; that keeps `~/.dsh` and `~/.agents` out, **not** the inherited environment (DSH's credentials service reads it first), the project's `.env` or its skill folders, nor the filesystem (`dsh-fs-local` has no sandbox). In the [plugin compatibility](docs/evals/plugin-compatibility.md) matrix (re-measured 2026-10-12, on rutis 0.9) DSH's top 20 go from 3 to 4 usable (`@dfy-plugins/dsh-visualize`); what still blocks the rest is DSH's app — its web UI, model layer, agents and settings, among others. pi unchanged at 12/20. ## 0.25.3 (2026-10-12) Docs only; no code changes. - **A skill for agents building on yoagent**: `skills/yoagent/SKILL.md` (AgentSkills format), now **shipped in the crate** — so it sits in every user's cargo registry at the version they build against. Which feature for which job, recipes (compiled by `tests/skill_test.rs`), tips and pitfalls, and what changed per version. Plus `llms.txt`, a map of every docs page for agents that browse instead. ## yoagent-rutis 0.3.0 (2026-10-12) yoagent-rutis only; yoagent is unchanged (0.25.2). Requires yoagent 0.25, **rutis 0.9** and **Rust 1.88**. - **rutis 0.9** (breaking: rutis types are in this crate's API, so this is a minor bump): `rutis`, `rutis-bridge` and `rutis-loader` 0.9, and the npm (`@arcships/rutis`, `@arcships/rutis-runtime`) and PyPI (`rutis`) packages at 0.9.1. Move the host and the language runtimes together: a 0.9 host sends call origins that a 0.8 runtime rejects. rutis 0.9 needs Rust 1.88 (yoagent itself stays at 1.86), Node 22 (pi: 22.19) and Python 3.10. A Node plugin that disposes itself is now reported to the host. - **Bun** (`bun` feature): the same TypeScript plugins in Bun 1.4+ runtimes, rows `bun:`, no build step. Local only for now (rutis's Bun runtime cannot listen remotely yet). - **Go** (`go` feature): plugins built with the rutis Go SDK (Go 1.24+), each binary a runtime started when a `go:` row needs it. A handler is a map of Go functions; a hook's `context.Context` is cancelled when the host gives the call up, the same contract as a JavaScript hook's `AbortSignal`. Example: `plugins/go/main.go`. - **16 MiB per message.** rutis 0.9 closes a channel on a message over 16 MiB, which ends the runtime and every plugin in it. The bridge keeps every call under 15 MiB: the conversation (`messages`) and events reach the plugin with their images as text when they would exceed it — a call's own `args` never change — and an `after_tool` output or `call_tool` arguments still that large are not sent at all (the output is withheld, the tool call fails). The DSH and pi adapters cap a result's images at 12 MiB of base64. - **The run's conversation, on request**: a handler can ask for `messages` (yoagent's LLM messages) on the `Turn`, `ToolCall` (`before_tool`) and `Stop` it sees — `Handler::with_messages()` in Rust, `options.messages: true` for TypeScript / Python plugins. Off by default (it copies the conversation for every call); handlers that did not ask never get it. - pi adapter: what does not map (commands, shortcuts, events yoagent never fires) is reported at `info`, not `warn`: it is expected, not a fault (refusals stay warnings). - **pi adapter: `message_end` and `context` handlers are checked, not refused.** Each message goes (a copy) to the `message_end` handlers once — the assistant message before any of its tools runs — and each request's messages to the `context` handlers; a handler that only reads works, one that would change what it got stops the adapter (yoagent cannot apply the change), and a check cut short by a timeout or cancel does not pass. The final answer is checked after it was given: a rewrite there fails the run only for a required bridge, and refuses later runs. In the [plugin compatibility](docs/evals/plugin-compatibility.md) matrix this takes pi's top 20 from 9 to 12 usable packages (pi-lens, pi-powerline-footer, context-mode; refused 8 → 5). ## yoagent-rutis 0.2.0 (2026-10-10) yoagent-rutis only; yoagent is unchanged (0.25.2). Requires yoagent 0.25 and **rutis 0.8**. - **rutis 0.8** (breaking: rutis types are in this crate's API, so this is a minor bump): `rutis`, `rutis-bridge` and `rutis-loader` 0.8, and the npm (`@arcships/rutis`, `@arcships/rutis-runtime`) and PyPI (`rutis`) packages the plugins use at 0.8.0 — since 0.8 the packages in rutis's release train share one version. Depend on `rutis = "0.8"` too: a `rutis` 0.6 next to it is a second copy of the core, whose types do not match. (rutis 0.8 runs natively on Windows, but yoagent-rutis is untested there: its runtime tests are Unix-only, and the terminal UIs read the keyboard from `/dev/tty`.) - **DSH asks the user** (`plugins/dsh/host-dialogs.ts`, loaded on hosts that provide a `ui` service): an `ask` from a DSH tool policy becomes a confirm showing the reason and the arguments (yes allows; no, no answer in time or a failing host denies), and DSH's `ask_user_question` is answered through the frontends (select, multiple select, typed text; a question left unanswered fails the tool rather than inventing an answer). Without a frontend attached, DSH's own result as before. Load it with `ui` shared into the DSH runtime. - **DSH tool cards**: the DSH adapter returns a tool's own presenters with a successful result as `details.view = {call?, result?}` (DSH's card vocabulary: generic, terminal, diff, search, read, web); a card never fails its call. - **pi dialogs reach a frontend** (optional): with a host `ui` service and `plugins/pi/host-ui.ts` loaded next to the pi adapter, pi extensions' `ctx.ui.select` / `confirm` / `input` / `editor` / `notify` reach the user (pi's RPC mode, `hasUI` true); a question whose hook is cancelled is withdrawn by its key; without them, print mode as before. - The examples read the DeepSeek key from `DEEPSEEK_API_KEY` only (no `~/.dskey` fallback). ## 0.25.2 (2026-10-09) Patch release. yoagent-rutis (0.1.1) and yoagent-workers (0.2.0) are unchanged. **Behaviour change on Unix** (`BashTool`, below): a running command no longer receives the terminal's Ctrl+C; hosts should cancel or drop the call on Ctrl+C. ### Added - **Headers for HTTP MCP servers** (#275). `HttpTransport::with_header(name, value)` sends a header on every request of the session — `initialize`, the `initialized` notification, tool calls and the closing `DELETE` — so servers that need a token, an API key or a `User-Agent` can be reached. Setting a name again replaces it; values are marked sensitive; an invalid name, a value that is not visible ASCII, or a name the transport or HTTP client sets itself (`Accept`, `Content-Type`, `Mcp-Session-Id`, `Content-Length`, `Transfer-Encoding`, `Connection`, `Host`) is an error before anything is sent. Connect with `McpClient::connect_http_with(transport)` or `Agent::with_mcp_server_http_transport(transport)` (same handshake and timeouts as `connect_http` / `with_mcp_server_http`, which are unchanged). Works on wasm32. Tests: every request of a session carries the headers (DELETE included, mutation-checked), an agent connects through a configured transport, refusals, replacement, and a plain transport sends none. ### Fixed - **`BashTool` kills what a command started** (#277, suggested by @shahidcodes). On Unix, `bash` now leads its own process group, and a timeout, a cancel or dropping the call kills the whole group: pipeline stages, `&&` lists and background jobs no longer keep running. Only a process that starts its own session (`setsid`) escapes. A command that finishes on its own leaves a background job alone when the job's output is redirected (`cmd >log 2>&1 &`); one still writing to the tool's output keeps the call open until the timeout, which now kills it. **Behaviour change:** the command is no longer in the terminal's foreground group, so the terminal's Ctrl+C does not reach it — cancel or drop the call on Ctrl+C (the `cli` example now aborts the run), and a command prompting on `/dev/tty` (`sudo`, SSH) stops until the timeout. Windows is unchanged (the `bash` process only). Adds `libc` as a Unix-only dependency (already in the tree through tokio). Tests: timeout, cancel, drop and normal completion in `tests/tools_test.rs`, mutation-checked. ### Docs - **README:** a new loop diagram (the `Extension` hooks at their real points, `on_stop`'s continue, limits as a second exit), "Why yoagent", "Extend it" (a policy example, plugins and the pi / DSH adapters), "Runs where agents run" (Workers), What's in the box as a table, Contributing, Security and Acknowledgements. ### Tests - **HTTP MCP on wasm32.** `tests/wasm32.rs` runs an agent that connects to an HTTP MCP server, discovers its tool and calls it from the loop, through the host's `fetch` (a scripted server replaces the global `fetch`): handshake, `Mcp-Session-Id` replay and the tool result. The Workers guide notes that a stalled MCP stream has no idle read timeout of its own on wasm32. ## 0.25.1 (2026-10-09) Patch release: additive only. Ships with **yoagent-rutis 0.1.1** (requires yoagent 0.25.1); yoagent-workers is unchanged (0.2.0). ### GASP - **Plugin logs in the run's record (opt-in).** `GaspRecorder::extension()` (install on the agent: it learns each run's id) and `GaspRecorder::plugin_log_layer()` (add to the `tracing` subscriber) record a rutis plugin's diagnostics — the yoagent-rutis bridge's `log`, target `yoagent_rutis::plugin` — as `observation.created` on the run they belong to, through the summarizer, with `{source: "plugin", level}` metadata. Only lines whose `run_id` is one of the recorder's runs are recorded. The `gasp` feature now also enables `tracing-subscriber` (registry only). Test: `tests/gasp_plugin_log_test.rs`. ### yoagent-rutis - **pi extensions** (`plugins/pi/pi-extensions-adapter.ts`): the tools and tool policies of [pi](https://github.com/earendil-works/pi) coding-agent extensions, loaded unchanged with pi's own loader, as one yoagent handler following pi 1.1.0's semantics. `registerTool` → tools (pi's activation and first-registration-wins, `prepareArguments` and validation before the policies, only the judged arguments run), `setActiveTools` → an enforced allowlist, `tool_call` → `before_tool` (yoagent's built-ins under pi's names, arguments translated both ways, relative paths resolved where the policies look; an override of a built-in is enforced; `terminate` stops the run), `tool_result` → `after_tool` (a failing handler withholds the result), `input` → `on_input`, `before_agent_start` additions → a turn note. Fails closed: a load error, a failing `session_start`, an unfired deciding event (`context`, `message_end`, ...; unless `allowUnmapped`) or a pi tool named like a yoagent built-in (unless `withoutBuiltins`) refuses the load; other unmapped API (observer and session events, commands, renderers, providers, MCP servers) is reported, or refuses with `strict`. Example `pi_extensions` (any extension files; `--live` with DeepSeek; `--without`), test `pi_test`. `ctx.executeTool` runs pi tools as nested calls (pi's pipeline; never rejects); a deciding event registered mid-run stops that run; adapter warnings reach the host's logs. `pi.appendEntry` / `pi.sendMessage` are recorded in the adapter's in-memory session instead of throwing (pi tools that record their result this way no longer fail after the work). CI: a weekly `pi latest` job runs the adapter's tests against the newest pi. - **Images in tool results, across ecosystems.** TypeScript and Python handlers' `call_tool` results and `after_tool` edits take `content` blocks in yoagent's JSON shape (`{"type": "image", "data": , "mimeType"}` next to text blocks) instead of `text`, so pictures cross the bridge both ways (`after_tool` already saw `output.content`). The dsh adapter turns dsh image blocks (references into dsh's attachment store) into yoagent images through the `attachments` service when one is loaded; the rutis-agent example reads a runner's `{"content": [...]}` value as blocks (rutis-agent results are otherwise text). Rust handlers already returned full yoagent tool results. Tests: `content_blocks` unit test, TypeScript/Python round trip in `languages_test`, dsh with and without a store in `dsh_test`. - **Plugin logs reach the host.** The `yoagent` service gains `log(level, message, { run_id }?)`: a TypeScript/Python plugin's diagnostics go to the host's `tracing` output (target `yoagent_rutis::plugin`, with the run as a `run_id` field) instead of the runtime process's stderr. It never rejects (a rejected, uncaught call would end the plugin's runtime): any message, extra context fields ignored. In Python it is a coroutine to `await`. The dsh adapter uses it. Test: `tests/plugin_log_test.rs`. - **Docs: one contract, other ecosystems.** The extensions guide shows DSH and rutis-agent plugging in through `Extension` and the bridge with no core change (pi too, since #265); the bridge README states the adapter contract (map what the host honours, refuse what would decide unenforced, deny on failing policies, withhold on failing redactions, report what is ignored). CI now also runs the rutis-agent example. - Test fixes: `dsh_test` no longer reads the abort file mid-write (empty) as the result; `pi_test`'s refusal checks wait for the adapter to become active or fail (the loader counts a row still waiting for its Node runtime as settled, so on a slow runner a refusal could be read before it happened). ### Docs - **Design philosophy** (`docs/design-philosophy.md`): why yoagent is a loop library and not an agent, the one `Extension` contract, fail-closed rules, plugins and ecosystems outside the core, what it costs, and a checklist for what belongs in the core. Linked from the introduction and the README. - The yoagent-rutis adapter guide gives dated npm package counts for the pi and DSH ecosystems (packages, not plugins that run through the adapters). ## 0.25.0 (2026-10-08) ### Added - **Extensions** (`yoagent::extension`, #241): one plug-in contract for the agent lifecycle. An `Extension` is installed with `Agent::with_extension` / `SubAgentTool::with_extension` (an `Arc` is one too), and each run gets fresh `RunHooks` from `start_run`, so state for one run is isolated. The hooks: - `tools` (once per run); - `on_input`; - `before_model` (note, stop or fail); - `before_tool` (allow, modify or deny; `&self`, so parallel calls are judged concurrently); - `after_tool` (edit the `ToolOutput`); - `on_stop` (accept, continue or fail the final answer); - `on_event` (every event, in order); - `finish` (with a `RunOutcome` whose `end()` is a `RunEnd`). **Advisory or required:** a required extension's failure fails the run, which ends with an `Error` message prefixed `EXTENSION_FAILED_PREFIX`; tool calls not started yet are not run. **Every hook watches the run's cancellation**, and `finish` is bounded. **Partial tool output** is withheld while an extension filters tool output. **A rechecking policy** judges rewritten arguments again. `ClonedHooks` wraps hooks cloned per run. **`with_tree_extension`** applies an extension to every delegated run at any depth, so host policy reaches sub-agents (a parent's `ToolMiddleware` never did); a custom delegation tool passes the tree on with `Agent::delegated_from` / `AgentLoopConfig::delegated_from`, reading `ToolContext::{tree_extensions, delegation_depth, run_label}`. Also new: `Agent::with_run_label` and `Agent::with_max_stop_continues`. Guide: `docs/concepts/extensions.md`. - **`RunHooks::take_failure`**: a defaulted hook through which an extension hands over a failure it recorded but could not raise in place (from `on_event`, or background work). The loop asks before each turn, before a response's tools, before `on_stop` and when the run ends, so a required extension's recorded failure fails the run however it ends. `yoagent-rutis` uses it for plugin `on_event` / `tools` failures. - **`extension::Budget`**: a dollar limit, checked before each model request: once spend reaches it, the run stops (the request that goes past it still completes). Per run (a sub-agent's reported spend included), or `.across_runs()` (a session, or a whole delegation tree with `with_tree_extension`; `spent_usd()` reads the total). `Budget::for_model` returns `None` for an unpriced model; a negative or NaN limit panics. - **Extension examples**: `extension_policy` (deny/modify with `ClonedHooks`, `rechecks_modified_calls`), `extension_redact` (`after_tool` + `filters_tool_output`), `extension_verifier` (`on_stop` continues, `finish` reading `RunOutcome::end()`), `extension_budget` (per run and `.across_runs()`), `extension_tree` (`with_tree_extension` over a `SubAgentTool` and a hand-written `Agent::delegated_from` tool) and `extension_audit` (`on_event` + `finish`). Each runs offline on a scripted `MockProvider` and checks its own result, and CI runs them all; `-- --live` drives a real model (`DEEPSEEK_API_KEY` or `ANTHROPIC_API_KEY`). New `tests/extension_combos_test.rs` covers extensions together with every tool strategy, compaction, steering, `prompt_structured`, provider retries (with `Budget`), and middleware plus the tool gate on one call. - **The OpenAI Responses provider (the GPT-6 presets) and Azure OpenAI can carry reasoning across tool calls** (#228) — **opt-in, off by default, and not yet verified against the live API** (checked against recorded request shapes only): `ModelConfig::with_encrypted_reasoning(true)` (`OpenAiCompat::encrypted_reasoning`). With it off, requests are as in 0.24. Opted in, a reasoning model's request asks for `include: ["reasoning.encrypted_content"]`, the finished reasoning item is kept on its `Content::Thinking` block (`redacted` holds its `id`, `summary` and `encrypted_content` as JSON, `redacted_protocol` the API it came from; the readable summary stays in `thinking`), and the next request sends it back in place as a `reasoning` input item, before the call or message it led to — what OpenAI recommends for function calling with reasoning models. That call or message is replayed with its output item `id` (`fc_…` / `msg_…`, recorded in the same JSON as `call_ids` / `message_id`), since OpenAI refuses a reasoning item without its paired item; items with no replayed reasoning before them still go without ids. It goes only to the API that produced it: OpenAI's and Azure's encrypted reasoning do not cross, and Anthropic's or Bedrock's never reach either. A reasoning item with no output after it in its turn is not sent. `include` is sent, and reasoning items are replayed, only for a reasoning model (`ModelConfig::reasoning`, or a request that sends a reasoning effort), because a non-reasoning model rejects both with a 400 — so after `Agent::set_model` to `gpt-4.1` the history goes without reasoning items and without item ids; a `custom` Azure config has `reasoning: false`, so set it for a reasoning deployment. `store` is still not sent. A summary that arrives only in the finished item now becomes the thinking text. Verified against mock servers only; `tests/integration_openai_responses.rs` (`--ignored`, needs `OPENAI_API_KEY`) runs the two-turn exchange live. - **`prompt_cache_key` on the Responses API** (OpenAI and Azure, #228): the same key Chat Completions sends — `CacheConfig::session_key`, else derived from the system prompt — and none when caching hints are off. Not gated on a compat flag: the field belongs to the Responses API, and Azure documents it. - **OpenAI's Decisions API as a decision backend** (feature `decision`): `OpenAiDecisionBackend` (`POST /v1/decisions`, key from `OPENAI_API_KEY` at call time, `with_api_key` / `with_base_url` / `with_retry` / `with_safety_identifier` / `with_capabilities`) and the preset `DecisionModel::gpt_6_luna()`; another model id with `with_model`, a configured backend with `DecisionModel::from_openai_backend`. Noul → `predicate`, Choice → `choice`, Score → `score`, question ids → `name`, a JSON state → pretty-printed `input` text; answers matched by `name` (by position only when unnamed and one per question). A `refusal` answer fails the call with `DecisionError::Refused` naming the question (not retried, and it ends a fallback chain), so the tool gate and the input guard fail closed. Priced, after `prices::enable_bundled()`, from the new bundled entry `openai-decisions/gpt-6-luna` ($0.10 per million input tokens, output free) only while requests go to `api.openai.com`. Image input is not supported. **Tested against mock servers only — not run against the live API.** - **`DecisionError::Refused { message }`** (+ `DecisionError::refused`, `is_refusal`; feature `decision`): an OpenAI refusal returns it, and `DecisionModel::or` returns it at once instead of asking the next model, so a fallback never answers what the first model declined. The tool gate and the input guard still fail closed. - **`LlmCompaction::reprice(&self)`** and a defaulted `CompactionStrategy::reprice`; `Agent::reprice` now also re-prices the compaction summarizer, so an agent built before the pricing opt-in can be priced afterwards. - **A one-time `warn!` per model when a first-party price lookup misses** while only `YOAGENT_PRICES` / `install_override` / `install_fetched` is in effect (no bundled snapshot). `LivePrices` is `#[must_use]`. - **`SessionStats::compaction`** (`CompactionSpend { usage, cost_usd, requests }`) and **`Agent::compaction_spend()`**: what `LlmCompaction`'s summarization requests cost, priced at the summarizer's own `cost`. See the fix below. - **`yoagent-rutis` (unpublished): TypeScript and Python hooks get a cancel handle.** Every hook but `on_event` finds it in its first argument, as the field `signal`: a real `AbortSignal` in JavaScript, rutis's `Signal` in Python (`.cancelled`, `await signal.wait()`). It is aborted when the bridge gives the call up — the run cancelled (`Agent::abort()`), the hook's timeout passed, the plugin unloaded mid-call — and never once the call completed. A `call_tool` that passes `call.signal` on (to `fetch`, a child process, dsh's `execute`) now stops when its run is cancelled; before, a JavaScript hook always ran to completion. A field rather than an extra positional argument, so fixed-signature Python methods keep working. It is not plain data: Python's `json.dumps(call)` now refuses the argument (drop `signal` first). Tested end to end in both languages (cancel and timeout abort it; a completed call's handle never is). No yoagent core change. - **`yoagent-rutis` (unpublished): dsh tools as yoagent tools.** `plugins/dsh/dsh-tools-adapter.ts` offers every tool of a DeepSeek Harness tool registry (`@deepseek-ai/dsh-tools`' `tools` service) to yoagent agents: `call_tool` runs dsh's `execute` with the bridge's cancel handle as dsh's `signal`, an `isError` result is an error tool result, and `before_model` adds the system-prompt sections dsh plugins wrote (the harness's own left out, capped at 2000 characters) as a turn note. dsh plugins run unchanged in rutis's Node runtime; `plugins/dsh/package.json` pins dsh 0.2.0-rc.2, `@deepseek-ai/cordis` 4.0.4 and `dsh-free-search` 0.8.1, in its own install (the language tests' stays small). `examples/dsh_tools.rs` (feature `node`) runs the unchanged `dsh-free-search` plugin's `platform_search` for a scripted model (it searches the web: needs network) or DeepSeek (`--live`) and checks its outcome. `tests/dsh_test.rs` covers the adapter with a no-network fixture dsh plugin (`plugins/dsh/fixture-tools.ts`): tools offered, text, `isError`, the prompt note, and a cancelled run aborting a slow dsh tool through its signal; CI's `rutis-bridge-languages` job installs `plugins/dsh` too. - **`yoagent-rutis` (unpublished): rutis-agent's tools as yoagent tools**, an example workspace (`examples/rutis-agent-tools/`, its own `[workspace]`, not built with the crate): a rutis plugin maps every `ToolDef` of rutis-agent's `ToolRegistry` to a yoagent tool per run (failures are `ToolError`s, yoagent's cancel token is the one rutis-agent's `execute` watches), shown with rutis-agent's real `replace_text`, a tool registered into the registry while the host runs (offered on the next run), and a cancelled call; scripted, or `--live` with DeepSeek, self-checking. **rutis-agent comes from git** (the rutis repository at tag v0.7.0, the one on rutis 0.6; crates.io's 0.2.0 is on rutis 0.2) **with a `[patch.crates-io]` for `rutis`**, so the graph holds one `rutis` crate. ### Changed - **The Azure OpenAI and OpenAI Responses providers share their request code** (#228). Azure's request builder and read loop were a copy of the Responses provider's; both now use one (`provider/responses_request.rs`), and Azure keeps only its endpoint, `api-key` header and legacy deployment override. The bodies they sent were already identical; the new fields above reach both. - **`yoagent-rutis` (unpublished) moves to rutis 0.6** (`rutis = "0.6"`, built against 0.6.1; 0.6 made rutis's public types `#[non_exhaustive]`, 0.6.1 added optional APIs). - **`yoagent-rutis` (unpublished) is rebuilt on `Extension`** (#250). `RutisBridge::install(&root)` provides a `yoagent` `Registry` service; plugins register a `Handler` (a name plus any of `tools`, `before_tool`, `after_tool`, `before_model`, `on_input`, `on_stop`, `finish`, `on_event`, with plain-data arguments: `ToolCall`, `Turn`, `Input`, `Stop`, `RunInfo`), tied to the plugin's lifecycle; `bridge.extension()` is one `impl Extension` (`RutisExtension`) whose `start_run` snapshots the handlers. The host sets `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()` and per-hook timeouts (60 s policy, 30 s input, 5 s turn). Installed with `with_tree_extension`, plugin policy now reaches sub-agents. New for plugins: `after_tool` (redaction), `on_stop` (verifiers), `finish`. Removed: the four adapters (`PluginToolSource`, `RutisToolMiddleware`, `RutisTurnHook`, `RutisInputFilter`), `attach` / `attach_sub_agent` / `AgentRutisExt`, `ToolRegistry`, `ToolCallEvent` / `ToolVerdict` / `ToolPolicy` / `TurnEvent` / `InputEvent`, and the `event_sender` tee (every run's events are now published on the bus from `on_event`, as `AgentEventEmitted` with `run_id()`, `label()` and `depth()`). The bus chains and their workarounds (frozen arguments, recorded denials, `mark_judged`, liveness checks around each dispatch) are gone with them. A handler whose plugin unloads mid-run is unavailable for that run: its tools fail, its `before_tool` denies, its `on_input` rejects, its `after_tool` withholds (without failing a `required()` run — an unload is not a failure). A failing `on_event` handler is switched off alone: bus publishing and the other handlers go on, and a `required()` run fails at its next decision point (a failure in the run's last events is logged). - **`yoagent-rutis` (unpublished) runs TypeScript and Python plugins** through rutis-bridge 0.7 (#250; features `node`, `python`, `websocket`, passed through; the default build stays Rust-only). `RutisBridge::install` then also provides the registry as the host service `yoagent`: a plugin calls `register(name, handler, options?)` with an object (or Python dict) of async functions passed by reference, and passes the returned unregister function to `ctx.effect`; a runtime that exits or crashes has its handlers removed. Same hooks and plain-JSON arguments as Rust handlers; `on_event` is opt-in and filtered by event type (`options.events`). The handler shape ships as `plugins/yoagent.d.ts` (no SDK package yet), with one TypeScript and one Python example plugin, a host example (`examples/language_plugins.rs`), end-to-end tests against real Node 24 / Python 3.12 runtimes, and a CI job (`rutis-bridge-languages`). The bridge still builds on 1.86. An abandoned call is cancelled through the hook's `signal` (see Added); Python coroutines are cancelled too. - **The decision features run as extensions** (#241 dogfooding). `with_tool_gate`, `with_input_guard` and `with_decision_model` install a `ToolGate` (`before_tool`), an `InputGuard` (`on_input`) and the advisor (a `before_model` note); the gate and guard keep their old trait impls, now deprecated (below). Three ordering effects: - the gate runs after every middleware **and extension**, so it also judges arguments an extension rewrote; - the guard screens after all input filters, not in their list; - the advisor's hint comes before turn-hook notes and is judged once per turn rather than once per provider attempt (it was memoized per request, so the same request is sent). - **A Responses / Azure stream without `usage` warns** with `usage_missing = true` (as Bedrock does); usage stays zero. - **Anthropic and Bedrock no longer send OpenAI Responses reasoning summaries that carry encrypted reasoning** (#228; only arises with encrypted reasoning on). Anthropic used to replay a Responses summary as a `thinking` block with `signature: ""`, and Bedrock as unsigned `reasoningText` (to a non-Claude model); such a block now holds Responses-tagged encrypted reasoning in `redacted`, which both skip. A summary without encrypted content (a response that did not return any) is handled as before. ### Changed (breaking) - **Nothing is priced by default; pricing is an explicit, process-wide opt-in.** Until 0.24 every first-party constructor (`ModelConfig::anthropic` / `openai` / …, the named presets) and the decision presets `jev()` / `clef()` / `clef_flash()` priced themselves from the `prices.json` compiled into the crate. Now `ModelConfig::cost` is `None` from every constructor, decision presets are unpriced, and so `session_cost_usd()`, `total_cost_usd()` and the `llm_stream` span's `cost_usd` report *unpriced* and `Budget::for_model` returns `None` — until the process opts in, once, before building configs: - `yoagent::provider::prices::enable_bundled()` — offline and reproducible: the bundled snapshot (the same `prices.json`, still in the crate). Works on every target, wasm32 included. - `prices::enable_live(&PriceSource::ModelsDev).await` / `prices::enable_live_cached(&source, path, CacheOptions)` (native) — fetch now and install over the bundled snapshot; a failed fetch falls back to the snapshot, logs it and reports it (`LivePrices::fell_back()`, `origin.fetch_error()`). Free models stay $0 without an opt-in (`DecisionModel::local`, a loopback `DecisionModel::logprobs`, a zero-rate `cost` you set). A `YOAGENT_PRICES` file, `global::install_override` and `global::install_fetched` are still honoured and are opt-ins of their own for exactly the models they list. An explicit `cost`, `ModelConfig::with_prices(&table)` and `reprice()` work as before. New: `global::clear_bundled()`, `global::is_bundled_enabled()`, `global::pricing_enabled()`, `LivePrices`. Pricing never makes a network request unless you call a function that says it does. **An app that shows costs (a yoyo-style CLI) must add one call at startup**, e.g. `yoagent::provider::prices::enable_bundled();` — otherwise every cost reads as unpriced. Guide: `docs/concepts/pricing.md#enabling-pricing`. Also: a decision preset's "unlisted id" warning is logged only once prices are enabled, and price overrides for `cloudflare/*` entries are no longer reported as inert with the `decision` feature. - **`AgentMessage::Extension(ExtensionMessage)` is now `AgentMessage::Custom(CustomMessage)`** (#228), so it isn't confused with `Extension`, the plug-in contract. Match arms and constructors need the new names; `ExtensionMessage` remains as a deprecated alias of `CustomMessage` (a variant can't have an alias). **Saved data is unchanged:** `AgentMessage` is untagged, so the variant name is never serialized, and `CustomMessage::new` still writes `role: "extension"`. For example, yoyo-evolve matches `AgentMessage::Extension` in five places. Migration: `AgentMessage::Extension(m)` → `AgentMessage::Custom(m)`, `ExtensionMessage` → `CustomMessage`. - **`yoagent::provider::sse` is private; `drive_sse` and `SseEvent` are removed** (#228). Nothing used them: every SSE provider reads its own stream. The module's one remaining function, `open_event_source`, was already crate-private. - **`AgentLoopConfig` is `#[non_exhaustive]`; build it with `AgentLoopConfig::new(provider, model)`.** `new` sets every other field to the default the struct-literal examples used (no API key, thinking off, no context management, no hooks, parallel tools, the default retry policy). Fields stay public, so set what you need afterwards: `config.max_tokens = Some(1024);`. A struct literal no longer compiles outside the crate. Every field added so far was a breaking change for code calling `agent_loop` directly, which is why recent features went through task-locals; new fields (such as `#241`'s extensions) no longer break anyone. `Agent` and `SubAgentTool` users are unaffected. - **`ModelConfig::openai_responses` infers `reasoning` from the model id** (#228). It was `true` for every id; it is now `false` for `gpt-3*`, `gpt-4*` (`gpt-4o`, `gpt-4.1`, …), `chatgpt-*` and any id containing `-chat` (`gpt-5-chat-latest`), and stays `true` for everything else (the o-series, `gpt-5*`, the GPT-6 presets, unknown ids). Without it, with encrypted reasoning turned on, `openai_responses("gpt-4.1", ..)` with `ThinkingLevel::Off` would be sent `include: ["reasoning.encrypted_content"]` and fail with a 400 ("Encrypted content is not supported with this model"). Set `config.reasoning` to override. A `ThinkingLevel` set on one of those ids now logs the existing "not marked reasoning-capable" warning (and still sends the effort). Breaking only if you read `config.reasoning`; set it to override. ### Deprecated - **The 0.10-deprecated constructors are hidden from the docs** (#228): `Agent::new` / `with_model` / `with_model_config` and the same on `SubAgentTool` still compile (with a deprecation warning) but no longer appear on docs.rs. They will be removed in 1.0, as their notes say. - **`ToolGate` as a `ToolMiddleware` and `InputGuard` as an `AsyncInputFilter`.** Install them with `with_tool_gate` / `with_input_guard` (or as extensions), and drive them outside the loop with the new `ToolGate::decide(&call)` and `InputGuard::screen(text)`. Rust cannot mark a trait impl `#[deprecated]`, so the first use of each in a process logs a warning; both impls will be removed in a later release. A gate installed as a middleware runs before every extension and before any middleware added after it, so it does not judge arguments those rewrite. - **`ExtensionMessage`** is a deprecated alias of `CustomMessage` (see the rename above). ### Fixed - **A cancelled run no longer executes the tool calls it had not started** (#243). If the run was cancelled while the model's response arrived, its tool calls used to run anyway, and the cancel was only seen at the next turn: after a user pressed cancel, the agent could still run a command or write a file. Such a call is now answered with an error result ("Tool call not run: the run was cancelled.") and never runs, and the run ends with `[Agent stopped: cancelled]` as before. A tool already running still gets the cancel through its `ToolContext`. - **Every message in the history has its events** (#243). A final failure or a cancel before the provider sent any output appended its assistant message without a `MessageStart`/`MessageEnd`, so transcripts rebuilt from events missed it. It is now announced with both. This includes a run aborted during a retry's backoff: after the `ProviderRetry`, its `Aborted` message arrives as a `MessageStart` and `MessageEnd` with no text, also through `retry_safe_events`. A retried attempt that failed before any output still gets only its `ProviderRetry`. - **Every `TurnStart` has a `TurnEnd`** (#243). A turn stopped by an execution limit, or by `on_before_turn` returning `false`, ended without one. It now ends with the history's last message (the stop marker, for a limit) and no tool results. These change what consumers see: a run can now send more events (a `MessageStart`/`MessageEnd` pair, a `TurnEnd`) and run fewer tools than before. A consumer that counts events, or relied on tools running after a cancel, should check them. - **A sub-agent cancelled before it answered fails its delegation** with `ToolError::Cancelled`. It used to return "(sub-agent produced no text output)" as a success. - **The `*_with_sender` methods run on the same spawned path as `prompt` / `continue_loop`** (#228). Dropping one of their futures (a `tokio::time::timeout`, a `select!`) used to drop the loop mid-turn, lose the agent's tools and leave it "streaming" for good, so the next prompt panicked. Now it cancels the run: the run ends aborted, its last events (with `AgentEnd`) still reach the sender while the `Agent` lives (call `finish` after a timeout rather than dropping the agent), and the next `finish` or prompt restores the agent's history and tools. A panic in the run still propagates to the caller. The spawned run keeps the caller's tracing span as its parent (this also fixes `prompt` / `continue_loop`, whose loop spans used to start a new trace). `finish` and `reset` are also safe to cancel now. The four run starters share one implementation, and the stale "misuse-`Result` variant planned for 0.10" notes are gone. - **`LlmCompaction`'s summarization spend now reaches the run's stats** (#228). It was reported only on `ContextCompacted` events and in an `info!` log, so `SessionStats`, `total_usage()`, `total_cost_usd()` and `Agent::total_cost_usd()` under-counted every session that summarized. The spend is a separate bucket, `SessionStats::compaction`, so `usage`, `turns` and `cost_usd` stay the main model's own turns. Details: - **Totals change:** `total_usage()` and `total_cost_usd()` (on `SessionStats` and `Agent`) now include it, so they are higher for sessions that summarized; with a summarizer that has no price (`cost: None`), `total_cost_usd()` is now `None` rather than a figure that left the summaries out. - **When it is counted:** the request runs on its own task, so its spend is counted by the run whose next compaction step finds it finished. A request still in flight when a run ends is counted by the next run that uses the strategy (`Agent` keeps one strategy across runs). Each request is counted once, whether its briefing was spliced, discarded or rejected (a `Length` or `Refusal` summary was billed too); a request that failed or timed out reported no usage. - **Sub-agents:** a child run's compaction spend folds into the parent's `compaction` bucket (one bucket for the delegation tree, like `decision`), not into `sub_agents`. `extension::Budget` counts it in a sub-agent's reported total, and (see below) the run's own. A child whose summarizer is unpriced now reports an unpriced total, so `Budget` prices that child's whole usage at the budget's own rate (as it already did for unpriced decision spend). - **Serialized stats:** the field is omitted when empty and defaults when absent, so stored `SessionStats` still deserialize. - **`extension::Budget` counts the run's own decision and compaction spend** — tool gate, input guard and advisor evaluations, and `LlmCompaction` summaries — each at its model's price, or the budget's own rates when unpriced. It counted only assistant turns and sub-agents. - **A failed decision evaluation's spend is recorded:** a response that arrived but could not be used (a refusal, an unusable answer, one rejected by the central check) adds its reported usage to `SessionStats::decision` / `total_cost_usd` (built-in OpenAI, SystemOne and logprob backends). A 2xx whose body is unreadable counts as unpriced spend, never $0. Custom backends (`from_backend`) still report no spend on failure. - **An empty `with_api_key` fails before sending** (`OpenAiDecisionBackend`, `SystemOneBackend`, `LogprobBackend`, `DecisionModel::with_api_key`): `MissingApiKey` instead of sending `Bearer `. - **An `Agent` run carries the caller's tracing subscriber** into its spawned task (and the extension observer and compaction tasks do too), so a subscriber set with `tracing::subscriber::set_default` / `with_default` sees runs on a multi-thread runtime. - **Responses / Azure, encrypted reasoning:** a reasoning item whose following output item has no id is no longer stored or replayed (warned) — it would 400 every later request — and an unparseable reasoning `output_item.done` ends the previous item's range. - **yoagent-rutis: a TypeScript/Python handler's `on_event` delivery no longer fails silently.** A panic during delivery is caught, and a delivery task that ended (seen on `send` or `flush`) is recorded; either is the handler's failure (a required extension fails the run, an advisory one logs it). Before, the remaining events were dropped and the run never failed. - **A cancelled run no longer blames an extension for a withheld tool result.** When the run is cancelled while an extension's `after_tool` is still working, the result is still withheld (a redactor may not have run), but the text now says the run was cancelled before the extension finished, instead of "extension '…' did not finish processing it", which reads like the extension failed. - **`list_files` reports what it couldn't read** (#260, found by yoyo). `find` skips an unreadable subdirectory, prints the error on stderr and exits non-zero; the tool ignored both, so a partial listing read as complete. The listed files are still returned, now with the errors under `Warnings` in the text and in `details.warnings` (capped); a listing with no files and an error fails, as `search` does. ## 0.24.3 Reliability fixes from the October codebase review: overloaded providers are retried, failed streams are errors rather than truncated answers, MCP over stdio is robust, `search` keeps partial results, sub-agent limits are configurable, and the onboarding paths work as written. No breaking changes. Two behaviours to know about: - A run cancelled while tools run now ends with an `[Agent stopped: cancelled]` user message in its history. - A bare `ModelConfig::anthropic("claude-…")` now infers its thinking mode and native structured outputs from the id. ### Added - **`SubAgentTool::with_execution_limits(ExecutionLimits)`.** A delegation's token and duration caps were hard-coded (1M tokens, 5 minutes), with only `with_max_turns` adjustable. Because the token count includes every turn's whole prompt, a sub-agent working over a large context reached the cap within a few turns, and nothing could raise it. All limits are now settable; `with_max_turns` still changes only the turns. The defaults are unchanged. ### Fixed - **MCP over stdio:** - **Response matching:** a request now waits for the response with its own id. The transport used to read exactly one line as the answer, so a logging or progress notification the server sent first failed the call and left every later call off by one. - **Server messages:** server notifications are skipped. Server requests are answered: `ping` succeeds and anything else is declined (logged at `WARN`), so the server does not wait. Stale responses and non-JSON lines (logged at `WARN`) are dropped. - **Handshake:** `notifications/initialized` is sent as a notification (`JsonRpcNotification`, `McpTransport::notify`). It used to be a request awaiting a reply that a spec-compliant server never sends, so connecting could hang. If a built-in transport cannot deliver it, the connect now fails (a custom transport that keeps the default `notify` stays best-effort), and the whole handshake is bounded at 60 s. - **stderr** is drained into the log whatever its encoding; a server that logged more than a pipe holds used to block. When the server exits, the error is now `McpError::Transport("Connection closed: MCP server '…' exited with …; last stderr: …")` with its exit status and last stderr, instead of a bare `ConnectionClosed`. - **The server process** is killed when the transport is dropped. - **Interrupted writes:** a message cut off by a cancelled call no longer garbles the next one. - **MCP tool calls** honour the run's cancellation. Over stdio they are also bounded at five minutes from when the call is sent (`McpClient::with_call_timeout`, `McpToolAdapter::with_call_timeout`). HTTP calls have no whole-call bound, since the transport's idle timeout already ends a stalled one. A hung stdio server used to hang the agent, with the client lock held so every tool from that server waited too. - **MCP content** kinds this crate does not model reach the model as text: a text `resource` as its own text, anything else as its JSON with large base64 payloads replaced by their size, logged once per type. They used to make the whole call fail to parse. - **`search` keeps its matches when some files cannot be read.** `rg` and `grep -r` exit 2 when any file errors (permission denied, a file removed mid-walk), even after finding matches. The tool reported that as "Search error" and dropped every match. Matches now come back followed by the tool's stderr under `Warnings:`; `details.warnings` holds the same text (capped at 2,000 characters) or `null`. A search that found nothing and hit an error still fails. A search that had warnings and exited 0 (a GNU grep `warning:` line, say) also no longer fails. - **Overloaded providers are retried.** HTTP 529 (Anthropic "overloaded") and 503 ("service unavailable") were `Api` errors that ended the turn at once. They are now `RateLimited`: backed off and retried, honouring `Retry-After`. Anthropic's in-stream `overloaded_error` and Bedrock's `serviceUnavailableException` are retried the same way. Other 5xx responses still are not. `RateLimited` carries no message, so the server's explanation is logged at `WARN`, and its text now reads "Rate limited" (or "…, retry after 1500ms") instead of "retry after Nonems". - **A failed stream is no longer returned as a finished answer:** - **OpenAI-compatible:** a mid-stream `{"error": …}` chunk (OpenRouter and other gateways) parsed as an empty chunk, and `finish_reason: "error"` read as a normal stop. The turn ended as `Ok` with the partial text. Both are now errors; a rate limit or overload among them (a string code such as `rate_limit_exceeded`, or a numeric 503/529) is retried. - **Gemini:** - A stream that ends without a `finishReason` is a retryable `Network` error. A drop after it keeps the response, so it is not billed twice. - A prompt blocked via `promptFeedback.blockReason`, and `IMAGE_SAFETY`, are a `Refusal` with a message. - `MALFORMED_FUNCTION_CALL`, `UNEXPECTED_TOOL_CALL` and `TOO_MANY_TOOL_CALLS` are an `Error` carrying Gemini's `finishMessage`. Any other unhandled finish reason is logged. - An HTTP 503 honours `Retry-After`, and an in-stream overload (`UNAVAILABLE`, numeric 503) is retried. Each of these used to come back as an empty or truncated `Ok`. - **Vertex AI** now uses Gemini's stream parser instead of an old copy (215 lines removed). It gains every Gemini fix: transport errors, in-stream errors, safety refusals and `ToolUse` being kept. Synthesized tool-call ids change from `vertex-fc-N` to `google-fc-N`, and a server-sent `functionCall.id` is used when present. - **A cancel while tools run is marked.** The run used to end with the last assistant message still `ToolUse`, indistinguishable from a normal stop. A cancelled sub-agent's delegation was therefore reported as a success. The run now appends `[Agent stopped: cancelled]` (`agent_loop::CANCELLED_MARKER`), and `SubAgentTool` reports it as a failure. A run cancelled before its first assistant message gets no marker (a sub-agent cancelled that early still returns its "no text output" fallback). - **`ToolExecutionStrategy::Batched { size: 0 }`** panicked in `chunks(0)`; it now runs as size 1. - **`ModelConfig::anthropic(id, ..)` infers the Claude options from a `claude-…` id** (`AnthropicCompat::for_claude_id`). It used to leave them unset: - `anthropic("claude-haiku-4-5", ..)` got adaptive thinking, which Haiku 4.5 rejects. - Every 4.5+ id went without native structured outputs and fell back to tool-forcing, which Fable 5.1 and Opus 5.5 reject. A non-Claude id (a model behind a Messages-protocol gateway) is unchanged. The context window and output limit stay generic (200K / 16K); use a preset (`claude_sonnet_5()` …) to get the model's own. The provider docs' snippets that set `config.anthropic` now start from `for_claude_id`, so they no longer switch native structured outputs off. ### Docs - **`max_total_tokens` explained.** Each turn counts its whole prompt (cached tokens included) plus its output. The prompt resends the conversation, so a run's total grows much faster than the conversation itself: the 1M default is reached around turn 16 of a run at a 60K-token context. All limits apply per run (each `prompt` starts at zero). The rustdoc and the context-management guide now say so, with a table, the settings for long agentic runs, and sub-agents' fixed caps. Behaviour is unchanged. - New guide: [Testing Your Agent](docs/guides/testing.md), covering `MockProvider`, testing a middleware alone, and abort. Its examples run as `tests/testing_guide_test.rs`. - **The `cli` example:** - It no longer reads `ANTHROPIC_API_KEY` for every provider. The README's `--provider groq` command panicked without one, and with one exported the Anthropic key was sent to Groq, OpenAI or a local server. - Each provider now reads its own variable; `API_KEY`, when set, is sent to whichever provider is selected. - **Quick starts:** the docs.rs front page, `quick-start.md` and `examples/basic.rs` use `ModelConfig::claude_sonnet_5()` instead of a bare `anthropic()` config. - **The README's Ollama snippet** gains the `/v1` its base URL needs, and the try-it commands gain `ollama pull llama3.1:8b`. - **Decision-model latency:** "~100 ms" is replaced with what we measured, a few hundred ms per request. ## 0.24.2 Security fixes for the built-in tools and the Gemini provider, found in a codebase review. Upgrade if you use the file tools with `allowed_paths`, the `search` tool, or Gemini. No breaking changes. ### Security - **File-tool sandbox escapes (`write_file` / `edit_file` / `read_file` with `allowed_paths`):** - **A `..` after a missing directory.** `PathSandbox` dropped every `..` that came after a directory that did not exist yet. `root/new/../../x` was checked as `root/new/x` (inside the root), while `write_file` created `root/new` and wrote `x` one level *above* the root. - **A dangling symlink.** `root/link`, pointing at a missing file outside the root (a cloned repository can ship one), passed as a plain new file, and the write followed it to create the target outside. - **A relative path whose first component is missing** (`a/../link/x`) was collapsed lexically and never checked for symlinks, so it could escape through `link`. The same gap rejected a plain new file such as `newdir/a.txt` as "outside the allowed directories". - **What changed:** - Relative paths are anchored at the working directory before resolving. - `..` components are kept, and a path that climbs through a missing directory is resolved again. - A dangling symlink is followed and checked; a symlink loop is refused. - Sandboxed tools do their I/O on the path that was checked (`PathSandbox::io_path`). - Without `allowed_paths`, paths are still used as given. - **`search` flag injection.** The model's pattern went to `rg` / `grep` as a bare argument, so a pattern starting with `-` was parsed as a flag. `--pre=` makes ripgrep run a command on every file it searches. - The pattern is now passed as `--regexp=` (`-e` for grep), with `--` before the path. - `list_files` prefixes a path starting with `-`, so `find` cannot read it as an expression. - **Gemini API key in error text.** The key travelled in the URL (`?key=`), and reqwest includes the URL in its error messages. Any transport failure therefore put the key into logs, `ProviderRetry` events, the turn's `error_message` and GASP recordings. - It is now sent in the `x-goog-api-key` header, marked sensitive. - The key is trimmed, so one with a trailing CR/LF keeps working, as it did in the URL. - The header is not added when `ModelConfig.headers` already sets `x-goog-api-key`, or when there is no key. An `Authorization` header (a proxy's, say) does not affect it. - A proxy that forwards the `key` query parameter but drops unknown headers would need updating. ### Fixed - **A panicking tool no longer kills the run.** Tool `execute` is now contained the way middleware already was, including a panic while the call's future is created. The panic becomes an error tool result (`tool '' panicked: …`, logged with the `tool_call_id`) and the loop continues; a sibling call in the same batch is unaffected. Before, the loop task died and the run's messages were lost. The agent also silently lost all its tools for every later run. - **`bash`:** - Output is cut as bytes and then decoded, so a cap that falls inside a multi-byte character no longer panics (it did on large CJK output). - Each stream is read with a byte cap (`max_output_bytes` per stream), so memory stays bounded however much a command prints. The cap used to apply only after everything was buffered. - A timeout or cancel kills the `bash` process (`kill_on_drop`); it used to keep running. What it started (pipeline stages, commands in a `&&` list, background jobs) can still outlive it. - A timeout now returns the output printed so far, with the truncation note when the cap was hit. A read error on a stream is reported instead of looking like the end of the output. - An already-cancelled call no longer starts the command. - **`search` / `list_files`:** - `max_results` capped matches per file (`--max-count`), so a search could return far more than asked, all of it buffered. The tool now reads matches as they arrive, keeps the first `max_results` and stops the search at the next one, saying there are more (`details.truncated`). - Both tools kill `rg` / `grep` / `find` on timeout or cancel. ## 0.24.1 Retry-safe event streams for output that cannot take text back, and a loop fix for providers that never send `Done`. No breaking changes; everything new is additive. ### Added - **Retry-safe event streams** (#218): `retry::retry_safe_events(rx)` and `retry::RetrySafeEvents` hold back each provider attempt's events until it succeeds. A retried attempt's partial text never reaches the consumer; a final failure keeps its start and end (its `content` included) but not its deltas; an attempt left open is dropped, never released. For non-interactive, append-only output such as stdout on a pipe or a log. It covers the agent's own messages, not a sub-agent's `ToolExecutionUpdate`s. `docs/concepts/retry.md` also describes the pattern for interactive terminals: abort when a retry follows streamed text. When the abort lands in the backoff (by default about a second), the turn ends `Aborted` without sending the retry (tested). - **`clef-worker` example** (`integrations/yoagent-workers/examples/clef-worker/`): a complete Cloudflare Worker running a yoagent agent whose tool calls are gated by Clef through the Workers AI binding (`ToolGate` + `yoagent_workers::ai::clef`). Managed with the Cloudflare CLI (`cf`, `cloudflare.config.ts`). It reports each tool call's outcome (`ok` / `denied` / `gate_unavailable` / `failed`), a failed model call as a 502 and a turn-limit stop explicitly. Type-checked and linted for wasm32 in CI, and run live once with `cf dev` against the real Workers AI binding: Clef's first real calls (allowed a read and a requested delete; no denial was observed, because DeepSeek refused the injected instruction itself). - The `clef-worker` example reports `timing` (the run and each Clef call, in ms). Measured under `cf dev`: Clef a median 444 ms per gate check (12 calls, one Cloudflare-side failure, `5012`), the whole request 2.55 s; Jev, the same request natively, 267 ms (12 calls, no failures). Details in its README. - The `clef-worker` example can gate with Jev (`?gate=jev`, `TYPESAFE_API_KEY` secret) and was deployed to Cloudflare and tested on the edge: per gate check, Clef via `env.AI` a median 308 ms (12 calls), Jev via `fetch` 230 ms (11 calls), no failures; whole requests 2.07 s / 1.91 s. Jev's first run in a Worker. Deployed via `cf deploy --prebuilt` (cf beta.12's build step did not exit); the Worker was deleted afterwards. - `examples/decision.rs` asks Clef instead of Jev when `CLOUDFLARE_ACCOUNT_ID` is set, says which model it uses, and exits 1 when the request fails. ### Fixed - A provider that returns `Ok` without sending `StreamEvent::Done` no longer leaves its message open: the loop sends the `MessageEnd` with the message the provider returned. All built-in providers send `Done`; this affected custom ones. A failed event forwarder is now logged instead of ignored. ## 0.24.0 Cloudflare's Clef decision models, over the REST API and (new crate `yoagent-workers`) through a Worker's AI binding, plus two fixes. No breaking changes: `DecisionError::RateLimited` gains a `body` field, and the variant is `#[non_exhaustive]`. ### Added - **Cloudflare's Clef decision models** (feature `decision`): `DecisionModel::clef(account_id)` and `DecisionModel::clef_flash(account_id)` call `@cf/cloudflare/clef` / `@cf/cloudflare/clef-flash` on Workers AI, which speak the SystemOne API. The token is read at call time from `CLOUDFLARE_API_TOKEN`, then `CLOUDFLARE_AUTH_TOKEN`. Priced from `prices.json` (`cloudflare/clef` $0.24, `cloudflare/clef-flash` $0.09 per million input tokens) while requests go to `api.cloudflare.com`. Underneath, `SystemOneBackend::workers_ai(account_id, model_path)` and `SystemOneBackend::with_endpoint_url(url)` (an exact URL). The backend unwraps Cloudflare's `{"result": ...}` envelope and keeps Cloudflare's error text: a 429 for a used-up daily allocation (code 3036) is not retried, out of capacity (3040) is; a 2xx `"success": false` is `Invalid` (schema error 5006) or `Backend`. Tested against a mock server only. - **`DecisionError::RateLimited` carries the server's `body`** (new field; the variant is `#[non_exhaustive]`), shown in its message, with `DecisionError::rate_limited_with_body`. A SystemOne key read from the environment is trimmed, and a 401/403 names the variable it came from. - **`decision::parse_systemone_response(body, request)`**: the SystemOne response parser `SystemOneBackend` uses (lenient, Cloudflare's envelope unwrapped), public for backends that move the request themselves. - **`yoagent-workers` (new crate, `integrations/yoagent-workers/`)**: yoagent on Cloudflare Workers through the Worker's own bindings. First, `yoagent_workers::ai::{clef, clef_flash, AiBackend}` run Clef through the Workers AI binding (`env.AI`) as a `DecisionBackend` — no API token, no account id. Binding errors are read for Workers AI's codes: out of capacity (3040) is retried (`AiBackend::with_retry`), a used-up daily allocation (3036) is not. wasm32 only; tested under Node against fake bindings. Not yet on crates.io (it needs `parse_systemone_response`); yoagent's core does not depend on it. ### Fixed - **A sub-agent's retried attempt is marked in its updates.** A sub-agent forwards its text deltas to the parent's `on_update`, including those of a provider attempt that then fails and is retried. It now also forwards `[sub-agent retrying after: ]` on `AgentEvent::ProviderRetry`, like the existing `[sub-agent calling tool: ]` marker, so the failed attempt's partial text is not read as the start of the answer. - **An overflow reported by an empty 400/413 body is detected on the message.** Cerebras and Mistral answer an oversized request with an empty 400/413, which `ProviderError::classify` already treats as `ContextOverflow`. But the turn's recorded error (`"Context overflow: "`) holds no overflow phrase, so `Message::is_context_overflow()` returned `false` for it. It now recognises any turn that ended with a `ContextOverflow` error. ## 0.23.0 A minor bump because it breaks: the `native` feature below breaks builds that already set `default-features = false`. ### Breaking - **`default-features = false` now turns off the new `native` feature.** Before this release yoagent had no default features, so that line did nothing. It now removes the filesystem and shell tools, `default_tools()`, `StdioTransport` / `with_mcp_server_stdio`, `FileBackend`, `PriceTable::fetch_cached` and all of reqwest's default features. The last one is the dangerous part: without reqwest's TLS the build still succeeds, but every HTTPS provider call fails at runtime (as a retryable network error, so each turn spends its retries first). **Migration:** native users who set `default-features = false` add `features = ["native"]`. - **A retried provider attempt now ends with a `MessageEnd`, followed by the new `AgentEvent::ProviderRetry`.** Previously the abandoned attempt had no closing `MessageEnd` (and, depending on the scheduler, some or all of its events never reached consumers). Now every attempt that opened a message is closed: a retried one by a `MessageEnd` with `StopReason::Error` and an `error_message` saying it will be retried, then a `ProviderRetry { attempt, max_attempts, error, delay_ms }`. **Migration:** a consumer that treats an error `MessageEnd` as the turn's failure should check whether the next event is `ProviderRetry` (see [the event contract](docs/concepts/messages-events.md)). The bundled consumers do: the GASP recorder no longer records retried attempts as model calls, and `examples/release_smoke` / `examples/long_horizon` no longer report a run whose retry succeeded as failed. ### Added - **`wasm32-unknown-unknown` builds (e.g. Cloudflare Workers).** A new default `native` feature holds what needs a native host (see Breaking). `cargo build --target wasm32-unknown-unknown --no-default-features` builds the agent loop, all providers (over the host's `fetch`), HTTP MCP, `SharedState` in memory, sub-agents, compaction and the `decision` feature; `openapi` and `gasp` stay native-only, and WASI targets are not supported. New `yoagent::rt` gives `spawn`, `sleep`, `timeout`, `JoinHandle` and `Instant`: Tokio's own on native targets (unchanged behaviour, paused-clock tests intact), and the host executor, `setTimeout` and `performance.now()` on wasm32. `StreamProvider`, `AgentTool`, `McpTransport`, `CompactionStrategy`, `ToolSource`, `SharedStateBackend`, `TurnHook`, `ToolMiddleware`, `InputFilter`, `AsyncInputFilter` and `DecisionBackend` now require `rt::MaybeSend + rt::MaybeSync`. That is a blanket over `Send + Sync` on native targets, so existing implementations and bounds are unaffected; implementations that run on wasm32 use `#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]` / `#[cfg_attr(not(target_arch = "wasm32"), async_trait)]`. On wasm32, `LlmCompaction` takes its existing no-runtime path and never summarises, and there are no environment variables, so keys must be passed explicitly. Wall-clock reads on the agent path (`types::now_ms`, `ExecutionTracker`, SigV4) use `web-time`, which is `std::time` on native targets and the host clock on wasm32 (where `std::time` panics). Provider SSE streams disable the eventsource library's own reconnect (`retry::Never`): providers already stop at the first stream error and the agent loop owns retries, so it never took effect, but its timer panicked on wasm32 and a reconnect would re-send a completion request. `PriceTable::fetch` keeps its timeout on wasm32 (a per-request timeout). Items that need `native` carry a docs.rs badge. Guide: [WebAssembly & Cloudflare Workers](docs/guides/wasm-workers.md). CI gains a `wasm32` clippy job (default and `decision`, with a lint that bans the std clock and Tokio calls that panic there) and a native `--no-default-features` row. - **`AgentEvent::ProviderRetry`** (wire tag `providerRetry`), sent when a provider attempt fails with a retryable error and will be retried: the failed attempt (1-based), the attempts allowed, the error text, and the backoff it waits. See Breaking for how it pairs with `MessageEnd`. - **`ToolSource`: tools resolved per run.** A new trait (`yoagent::ToolSource`, module `tool_source`) with one async method, `tools() -> Vec>`, installed with `Agent::with_tool_source` / `SubAgentTool::with_tool_source`. Each source is consulted once at the start of every run (every `prompt*` and `continue_loop*` call; once per delegation for a sub-agent), and its tools are offered alongside the agent's own for that run only — they are dropped again when the run ends. On a name collision the agent's own tools win, then earlier sources, then the earlier tool within a source; each dropped duplicate is logged, and the run is never refused. The surviving sourced tools are sorted by name, so a source returning the same set in another order never changes the request prefix (any change to the *set* still rewrites the prompt cache — per-run consultation bounds that to run boundaries). Sources have no timeout: a sub-agent stops waiting when the parent run is cancelled; on an `Agent`, dropping the prompt future leaves its state untouched. A panicking source (even one that panics before returning its future) is contained and contributes nothing. For tool sets that change while the agent lives (plugin systems, reconnecting MCP servers); nothing changes for agents without a source. Non-breaking: `AgentTool`, `AgentContext` and `AgentLoopConfig` are unchanged. - **`yoagent-rutis` (new crate, `integrations/yoagent-rutis/`)**: a bridge that lets [rutis](https://crates.io/crates/rutis) plugins extend a live `Agent` — contribute tools (via `ToolSource`), gate tool calls, add turn notes, reject input and observe `AgentEvent`s on the rutis bus. Its own crate, not yet on crates.io (it needs a yoagent release with `ToolSource`); yoagent itself does not depend on rutis. - **wasm32 tests run in CI.** `tests/wasm32.rs` runs under Node with `wasm-bindgen-test`: the `rt` shims (`sleep` waits on the host timer, `timeout`, `spawn` and abort, `Instant`) and two whole agent runs, a tool turn and a retried provider failure on real timers. Lint alone cannot see code that compiles for wasm32 but panics or hangs there. Native dev-dependencies are now target-scoped so the test builds for wasm32; no library code changes. ### Changed - **A cancelled run ends as `StopReason::Aborted`, not `Error`.** When the provider returns `ProviderError::Cancelled`, or the run is cancelled while a provider call fails, the turn's message (and its closing `MessageEnd`) now carries `Aborted`, so `on_error` is no longer called for a cancellation. Cancelling during a retry's backoff ends the turn at once instead of after the delay and one more request. A cancelled `SubAgentTool` delegation still fails the tool call, and a cancelled `prompt_structured` still returns `StructuredPromptError::Provider` ("Cancelled"), as before. ### Fixed - **Failed provider attempts reach consumers deterministically.** On a retryable error the loop used to abort the event forwarder, so how much of the failed attempt's partial output had already reached consumers depended on the scheduler. The loop now drains the forwarder after each attempt, then closes the attempt (see Breaking). The final failed attempt is closed with the same error message the turn returns. History is unchanged: it holds only the successful answer (or the final error). A custom `StreamProvider` must drop every clone of its event sender before `stream` returns, or the loop waits for it. ## 0.22.0 ### Added - **`Content::Thinking` gains `redacted: Option` and `redacted_protocol: Option`**, and a `Content::thinking_redacted(protocol, data)` constructor, for reasoning a provider returns encrypted instead of as text (the Anthropic API's `redacted_thinking` data, Amazon Bedrock's base64 `redactedContent`). `redacted_protocol` records which API produced the payload, and it is replayed only to that API: neither API documents the other's encrypted reasoning as valid, so each provider skips the other's (and a block with no recorded protocol), logged at debug. An unknown `redactedProtocol` (written by a newer yoagent) loads as none recorded rather than failing the message. The variant is `#[non_exhaustive]`, so this is not a breaking change; both fields are omitted from JSON when `None` (`redactedProtocol` when present, `redacted_protocol` also accepted), so session files and the event wire format are unchanged for content without redacted reasoning. - **Amazon Bedrock API keys** ([#174](https://github.com/yologdev/yoagent/issues/174)). An `api_key` without `:` is a Bedrock API key and is sent as `Authorization: Bearer ` — unless it evidently is not one (whitespace or control characters, an `AKIA`/`ASIA` start, non-base64 characters, or the length of a bare secret key), which is refused so IAM credentials joined with something other than `:` never go out as a bearer token. `AWS_BEARER_TOKEN_BEDROCK`, AWS's variable for it, is read before the IAM variables, both by the provider (with an empty `api_key`, on every request: `AWS_BEARER_TOKEN_BEDROCK`, then `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (+ `AWS_SESSION_TOKEN`)) and by `resolve_api_key("bedrock")`. The SigV4 region comes from the endpoint host (`bedrock-runtime[-fips].` with any AWS partition suffix, dual-stack `api.aws` and VPC endpoints included), else `AWS_REGION`, else `AWS_DEFAULT_REGION`. ### Changed - **Behaviour change: Amazon Bedrock requests with IAM credentials are signed by yoagent** ([#174](https://github.com/yologdev/yoagent/issues/174)). If you route Bedrock through a local signing proxy (`aws-sigv4-proxy` and the like) with IAM credentials in the environment, yoagent used to send them as `Bearer access:secret` for the proxy to re-sign; it now signs itself, and a proxy `base_url` has no region in its host, so the request fails with `ProviderError::Auth` unless `AWS_REGION` is set. Set `AWS_REGION`, or put a placeholder `authorization` header in `ModelConfig.headers` so yoagent sends no credentials and leaves signing to the proxy. - **Behaviour change: `Agent`, `SubAgentTool` and `LlmCompaction` resolve a Bedrock key by protocol, not by provider string.** For any `BedrockConverseStream` config they pass an empty key and the provider reads `AWS_BEARER_TOKEN_BEDROCK` / `AWS_*` on each request. Before, a config whose provider was not exactly `"bedrock"` (say `ModelConfig::custom(ApiProtocol::BedrockConverseStream, "aws-bedrock", ..)`) picked up the generic `YOAGENT_API_KEY` / `API_KEY`; those are no longer sent to AWS. A custom `StreamProvider` registered for `BedrockConverseStream` now also receives an empty key, where it used to get `AWS_BEARER_TOKEN_BEDROCK` or the composed IAM credentials when the provider string was `"bedrock"`; it should read its credentials itself, or be given an explicit key. - **Behaviour change: `MIN_HEADROOM_RATIO` is 0.30 (was 0.15).** It is the lowest ratio of the context budget the headroom policy may compact to. For tool-heavy sessions the policy's "leave room for 30 turns" demand exceeds the budget and pins compaction to this floor. At 0.15 that dropped the opening task prompt in 91% of compactions at the default 96K budget (and the latest request in 41%). At 0.30 it is 0% and 2%, for about 7% more input tokens per request and no measurable change in compaction count or prefix-cache hit rate. Measured offline against the real compaction code with `examples/compaction_sweep` (#164, #150; see `docs/evals/compaction-defaults.md`); live cache hit rates are unconfirmed. Small budgets (around 26K) can still collapse, because the newest turn alone can exceed any target. With the headroom policy on, the floor also applies when `compact_target_ratio` is set below 0.30, so such a config now compacts to 0.30. To compact harder, set `compact_headroom_turns: None`: compaction then uses `compact_target_ratio` unfloored. ### Fixed - **The Anthropic provider keeps and replays `redacted_thinking` blocks** ([#197](https://github.com/yologdev/yoagent/issues/197)). The block did not deserialize, so it was dropped without a log, a later block padded the gap with an empty text placeholder, and it was never sent back. With thinking and tool use the API requires a turn's thinking blocks, redacted ones included, to come back unmodified, so a continuation could be rejected or lose the model's reasoning. The block is now kept in order at its own position (its `data` arrives whole in `content_block_start`) and replayed as `{"type": "redacted_thinking", "data": ...}` in place. Any other block type the provider does not surface (server-side tool blocks, `fallback`, types added later) and any unknown delta type is skipped with one warning per type naming it, instead of silently; skipped blocks leave no placeholder and later blocks keep their deltas. Stream events' `content_index` now always matches the block's position in the final message. A `content_block_start` or `content_block_delta` that does not parse is logged instead of dropped silently. Tested against mock streams, not the live API. - **Amazon Bedrock streaming works** ([#174](https://github.com/yologdev/yoagent/issues/174)). `BedrockProvider` parsed the ConverseStream body as newline-delimited JSON and silently skipped every line that did not parse. Real responses are binary `application/vnd.amazon.eventstream` frames, so nothing parsed and every turn came back as an empty *successful* assistant message (`StopReason::Stop`, zero usage). The payload structs did not match AWS's shapes either; streamed `toolUse.input` was never collected, so every Bedrock tool call ran with `{}`; text split across network chunks was decoded per chunk, corrupting multi-byte characters; a dropped connection returned the partial content as a success; and all text and reasoning went into the first block of each kind. The response is now decoded as event stream frames: bytes are buffered across network chunks, both CRC-32 checksums are verified (in-house, no new dependency; checked against the AWS SDK eventstream test vectors), and events are dispatched on the `:event-type` / `:message-type` headers with payload shapes taken from the AWS Bedrock Runtime API reference. Text, reasoning (with its signature, so it replays) and tool input accumulate per `contentBlockIndex`; tool input is parsed at `contentBlockStop` with the same rule as the other providers, so truncated or unparseable arguments — and a tool block that never closed — are answered with an error tool result instead of running on `{}`. Every documented `stopReason` is mapped (`max_tokens` → `Length`, `guardrail_intervened` and `content_filtered` → `Refusal`, `model_context_window_exceeded` → a context-overflow `Error`), and usage includes cache reads and writes. In-stream exceptions classify like their HTTP status (`throttlingException` → retryable rate limit, an over-long-input `validationException` → context overflow). A checksum mismatch, a truncated frame, a dropped connection, or a stream without `messageStop` (including an empty body) is now an error — except once both `messageStop` and `metadata` have arrived, when a later drop keeps the finished response instead of retrying (and re-billing) it. A `200` with a non-eventstream content type reports its body. Reasoning replays as Bedrock requires: a signed block as `reasoningText` with its signature, a redacted block as `redactedContent` (previously both failed — a redacted block was replayed as empty text with an empty signature, which Bedrock rejects). Reasoning without a signature is replayed without a signature key (it is optional, and non-Claude reasoning models such as gpt-oss never sign); to a Claude model, which verifies signatures, it is not replayed. A signature is never sent as `""`. Content this provider does not surface (images, citations, tool results, server-side tool use, and union members or event types added later) is dropped with a warning rather than silently. A response that ends without a `metadata` event reports zero usage, as the other providers do, and logs a warning with `usage_missing = true`. Tested against mock frames built to AWS's documented format, not against a live Bedrock endpoint. Requests are now also authenticated properly (next entry). - **Amazon Bedrock no longer sends the secret access key in a bearer header** ([#174](https://github.com/yologdev/yoagent/issues/174)). Without an `authorization` header in `ModelConfig.headers`, `BedrockProvider` sent `Authorization: Bearer {api_key}` — with `api_key` in the documented `access_key_id:secret_access_key[:session_token]` form, the secret key went out in a header, and Bedrock rejected the request. IAM credentials are now used to SigV4-sign the request (signing name `bedrock`; `x-amz-date`, `x-amz-content-sha256` over the exact body bytes sent, and `x-amz-security-token` for a session token); only the access key id and a signature are sent. An `authorization` header in `ModelConfig.headers` still wins and is sent alone (now matched in any case, and no longer requires `api_key` to contain `:`). Every authentication failure is a `ProviderError::Auth` before anything is sent, naming what is wrong but never a credential: no credentials, one half of the IAM pair in the environment, a variable that is not valid Unicode, a malformed `access:secret` value, a temporary (`ASIA…`) key without its session token, a value that cannot be an HTTP header (a newline, non-ASCII — previously a retried `Network("builder error")`), a `ModelConfig.headers` entry that SigV4 sets itself, a clock before 1970, or SigV4 with no region. Credential headers are marked sensitive (redacted from `Debug`). A request reqwest cannot build is now `ProviderError::Other` (not retried) instead of a retryable `Network` error. A trailing `/` on `base_url` is dropped (it produced `//model/…`). A `403` for an expired or not-yet-valid signature says to check the system clock. The model id is now percent-encoded in the request path as the AWS SDKs send it (`:` → `%3A`, `/` → `%2F`); ids without those characters are sent as before. The signer is hand-written (private `provider::sigv4`, on the RustCrypto `sha2` and `hmac` crates — two new dependencies) and pinned to AWS's `aws-sig-v4-test-suite` vectors; a mock-server test recomputes each signature from the request received. Not tested against a live endpoint. ## 0.21.0 ### Added - **Decision models, behind the new `decision` feature (off by default, no new dependencies).** `yoagent::decision` asks typed questions — Noul (yes/no), Choice (one of up to 255 options), Score (2–10 ordered levels) — about a text or JSON state and returns typed answers with probabilities and a confidence. Nothing is sent until you construct a `DecisionModel`; an API key in the environment enables nothing. - `DecisionModel` presets: `jev()` (TypeSafe, `TYPESAFE_API_KEY`, `TYPESAFE_BASE_URL`), `jev_opencode()` / `jev_opencode_free()` (OpenCode Zen, `OPENCODE_API_KEY`), `local(url)` (self-hosted SystemOne servers such as JevK5, no key); `from_backend` for anything else. Keys are read at call time. Builders `with_model`, `with_timeout`, `with_api_key`, `with_retry`, `with_cost`; getters `model`, `timeout`, `capabilities`. One-line `noul` / `choice` / `score` returning `NoulAnswer` / `ChoiceAnswer` / `ScoreAnswer`, `ask(state)` returning an `Ask` batch builder (`noul`, `choice`, `score`, `question`, `send`), and `evaluate` / `evaluate_request`. - Requests and answers: `Question` (`noul`, `noul_with_criteria`, `choice`, `choice_with_criteria`, `score`; accessors `kind`, `instructions`, `options`, `choice_criteria`, `noul_criteria`, `levels`), `QuestionKind` (`all`, `as_str`), `Request` (`new`, `question`, `get`), `Answer` (`kind`, `confidence`, `as_noul`, `as_choice`, `as_score`), `NoulAnswer`, `ChoiceAnswer` (probabilities in option order), `ScoreAnswer`, `DecisionUsage`, and `Evaluation` (`new`, `with_answer`, `with_cost_usd`; `model`, `usage`, `cost_usd`, `answers` in request order, `get`, `noul`, `p_true`, `choice`, `score`), plus `distribution_confidence`. - `DecisionBackend` trait with `Capabilities` (`new`, `with_max_choice_options`, `with_max_score_levels`, `with_batching`, `with_local`, `with_token_limits`, `supports`); `SystemOneBackend` (`new`, `typesafe`, `opencode_zen`, `with_api_key`, `with_api_key_env`, `with_base_url`, `with_retry`, `with_capabilities`, `endpoint_url`; lenient parsing, 429/529 and transport errors retried with `RetryConfig` backoff, server `retry-after` honoured) and `MockBackend` (`new`, `neutral`, `from_fn`, `push`, `push_error`, `with_capabilities`, `requests`, `request_count`). Requests are validated against the backend's limits before sending; an unsupported question type is an error, never emulated. Every backend's answers are validated (kind; finite probabilities and confidences in `[0, 1]`; a probability for every Choice option and every Score level, summing to 1 within `max(0.02, n × 0.005)` for `n` options or levels, for servers that round; choices among the options); answers nobody asked for are dropped. - `DecisionError` (`Clone`, `#[non_exhaustive]`, matched with `matches!`): `Http`, `RateLimited`, `Timeout`, `Invalid`, `Unsupported`, `Transport { message, source }`, `MissingApiKey`, `Backend { message, source }`, `BadResponse`; constructors `http`, `rate_limited`, `transport`, `transport_with_source`, `backend`, `backend_with_source`; `is_retryable`, `retry_after`. - Confidence is the backend's when reported, otherwise TypeSafe's `(n * p_max - 1) / (n - 1)` (`distribution_confidence`). - Pricing: `prices.json` lists `typesafe/jev-1.13.0` ($0.042 per million input tokens, output free). `jev()` evaluations are priced by the model id the API reports, only on TypeSafe's host; aliases, unlisted versions, gateways and `from_backend` are unpriced, `local()` is $0; a response that reports no usage is unpriced unless the handle is free. A non-batching request that fails or times out partway records the usage already billed. An unpriced handle keeps a cost the backend reports itself. The price audit records the entry as absent from models.dev (with a test that counts it). With the feature on, `typesafe` override entries are no longer reported as inert. - **`Agent::with_decision_model(model)`** — advisory features only, which never block, and which need skills or 40+ tools (otherwise nothing is sent): a skill hint and a tool hint, each at most one note appended to the request's latest user message — never the system prompt, never stored. One decision request per user message (memoized across its tool-calling turns), 2 s limit; any failure warns and adds nothing. `with_decision_advisory(Advisory)` tunes it: `Advisory::new` and `with_skill_hint`, `with_skill_need_threshold`, `with_skill_confidence_threshold`, `with_tool_hint`, `with_tool_hint_min_tools`, `with_max_tool_hints`, `with_tool_hint_min_probability`, `with_timeout`. - **`Agent::with_tool_gate(ToolGate::new(model))`** — an explicit, blocking opt-in: a `ToolMiddleware` that denies calls a decision model judges destructive and not requested by the user, judged against the user's request with the preceding exchange for short confirmations. Runs after every other middleware and **fails closed** (errors, timeouts, malformed answers, arguments too large to read, no user request). `ToolGate` builders: `with_destructive_threshold`, `with_requested_threshold`, `with_destructive_question`, `with_requested_question`, `with_check`, `with_timeout`. Covers only its own agent. Defence in depth, not a security boundary. `SubAgentTool` mirrors `with_decision_model`, `with_decision_advisory` and `with_tool_gate`. - **`SessionStats::decision`** (`DecisionStats`: `requests`, `failures`, `timeouts`, `usage`, `cost_usd`, `unpriced`; methods `is_empty`, `is_unpriced`, `merge`) — decision-model spend of a run, including async input filters and sub-agents; part of `total_cost_usd()`. Omitted from the wire when empty. - **Hooks for policy engines** (no feature needed). `ToolCallRequest` and `SessionStats` gain fields, but both are `#[non_exhaustive]`, so this is not breaking; `AgentLoopConfig` gains no field. - `ToolCallRequest::messages`, `ToolCallRequest::run_prompts` (the user messages this run was given, which compaction cannot remove), `latest_user_text()` and `user_request()` — middleware can see the conversation. `user_request()` never looks back past a compaction boundary or past the latest user message (even an image-only one); it falls back to the run's prompts, then `None`. It includes the preceding assistant text and earlier request only for a short reply to an assistant question. - `is_loop_injected(text)` (crate root) — whether a user-role text was written by the loop or by compaction. It recognises `llm_compaction::SUMMARY_MARKER`, `agent_loop::AGENT_STOPPED_PREFIX`, `agent_loop::LOOP_ABORT_PREFIX` (all already public), and the crate-private level-2 summary prefix, level-3 compaction marker and loop-detection nudge. - `AsyncInputFilter` (`filter`) and `Agent::with_async_input_filter` — input filters that await, in the same ordered list as sync filters; a panic is contained and rejects. `InputFilter` gains a provided `as_async()` (default `None`); `AsyncFilter::new` puts an async filter in `AgentLoopConfig::input_filters`. - `TurnHook` and `Agent::with_turn_hook` — an async hook before every LLM request that may append one transient note to the request's latest user message. Raw loops wrap their provider with `provider::TurnHookProvider::new`. `TurnContext` carries `run_prompts`; `TurnContext::new` / `with_run_prompts` for tests; `TurnContext::latest_user_text` and `user_request` as on `ToolCallRequest`. - **Decision models: any LLM with logprobs** (feature `decision`). `DecisionModel::logprobs(base_url, model)`, `DecisionModel::from_logprob_backend(LogprobBackend, model)` and `LogprobBackend` (`new`, `with_api_key`, `with_retry`, `with_temperature`, `with_max_choice_options`, `with_top_logprobs`, `with_min_label_mass`, `with_thinking_disabled`, `with_extra_body`, `temperature`, `is_local`, `endpoint_url`) turn any OpenAI-compatible `/chat/completions` server that returns logprobs (llama.cpp `llama-server`, vLLM, SGLang, LM Studio, hosted APIs) into a decision backend: one one-token completion per question (`max_tokens: 1`, `temperature: 0`, `logprobs: true`, `top_logprobs: max(configured K, label count)`, K = 20), labels `A`/`B` (Noul), `A`.. (Choice, the options in order) and `0`–`9` (Score), read from the first token's `top_logprobs`: tokens trimmed and case-folded, duplicates summed; no label in the top K, or labels covering less than half of the first-token probability (a thinking model's ``, an answer in words), is `BadResponse`; an absent label is bounded by the smallest reported probability instead of 0. Thinking must be off (`with_thinking_disabled` sends `chat_template_kwargs.enable_thinking = false`, not by default). Calibration is approximate; the backend's `with_temperature` rescales it. Choice up to 20 options by default (up to 26 when raised), Score up to 10 levels; Noul/Choice/Score only (a future kind is `Unsupported`). One request per question: the backend reports `batching: false` with 8 concurrent requests, so the questions of a request run concurrently and the spend of completed ones is recorded even when another fails or the call times out. No key unless set and no environment variable read; local and $0 only on a loopback host, otherwise unpriced until `with_cost`. `DecisionModel::with_api_key` and `with_retry` also apply to logprob models. - **`Capabilities::max_concurrent_requests`** (`with_max_concurrent_requests`, default 1) — for a non-batching backend, how many single-question requests `DecisionModel` runs at once. The split path now stops starting new questions after the first failure but awaits those in flight. - **Decision-model fallbacks.** `DecisionModel::or(fallback)` (chainable, flattened in order; each member asks with its own model id) tries the next model on any error a member returns, `Invalid` (e.g. a 422) included; each member is validated against its own capabilities (a request invalid for every member fails at once; one over a member's limits skips it unsent). One overall budget (`with_timeout` of the chain, judged by the clock, not the error kind) covers the chain; the gate's (5 s), input guard's (3 s) and advisory's (2 s) limits replace it, as they do a single model's; and `with_attempt_timeout` caps a member's attempt within it. Every attempt sent is recorded in `SessionStats::decision`. `model()` / `capabilities()` report the primary's; `fallback_models()` lists the rest. New error variant `DecisionError::AllFailed { attempts }` with `FallbackAttempt` (`model`, `error`, `was_sent`); it is retryable when any member's error was (the enum is `#[non_exhaustive]`, so this is additive). A request that cannot be built (a malformed base URL) is now `Invalid`, not a retried `Transport` error, for the SystemOne and logprob backends. - **`DecisionModel::from_arc(Arc, model)`** — wrap a shared or runtime-chosen backend. - **Calibration.** `decision::calibrate(model, examples)` and `calibrate_with(model, examples, CalibrationOptions)` measure a model on labelled `CalibrationExample`s (`noul`, `choice`, `score`, `new` with an `Expected` of `Noul`/`Choice`/`Score`; getters `state`, `question`, `expected`), evaluated with bounded concurrency (`CalibrationOptions::new`, `with_concurrency` — default 4 —, `with_target_precision`; getters `concurrency`, `target_precision`). `CalibrationReport`: `count`, `accuracy` / `brier` / `ece` (`Option`, `None` when nothing was evaluated; ECE over 10 bins with the `ReliabilityBin`s), Noul `best_f1` and `precision_threshold` as `ThresholdPoint`s, a `suggested_temperature` (grid search minimising NLL; approximate for logprob models), `models` per answering model with `mixed_models()`, and the `errors` (`CalibrationError`: `index`, `error`) and `skipped` examples — counted, never fatal. Its `Display` is for people, not a stable format. - **`Agent::with_input_guard(InputGuard::new(model))`** — an explicit, blocking opt-in built on `AsyncInputFilter`: one batched request of Nouls per prompt (default checks `injection` and `harmful`, each rejecting at `p >= 0.8`; they may change in minor releases — pin them with `without_default_checks` + `with_check`); a hit rejects the input with a reason naming the check. The whole input is sent; input over 32,000 characters (`with_max_input_chars`) is not sent. **Fails closed** (error, timeout — 3 s by default —, missing answer, or input over the limit rejects); `with_fail_open()` opts out. Builders `with_check` (a built-in id replaces that check), `without_default_checks` (independent of call order), `with_threshold` (panics on an unknown id or a value outside `[0, 1]`), `with_timeout`, `with_max_input_chars`; `check_ids()`. `with_input_guard` panics on a guard with no checks. Input with no text (image-only) passes unscreened; steering and follow-up messages are not screened (input filters see prompts only). `SubAgentTool::with_input_guard` screens a sub-agent's task. - **Hook builders** (no feature needed). `ToolCallRequest::new(tool_call_id, tool_name, args)` with `with_messages` and `with_run_prompts`, to unit-test middleware — `ToolGate` included — outside the crate. `ToolCallRequest::user_request_parts()` and `TurnContext::user_request_parts()` return `UserRequestParts` (`latest`, `reply: Option`, `source: UserRequestSource` — `Conversation` / `RunPrompts` —, and `run_prompts`; all `#[non_exhaustive]`), the same selection as `user_request()`, whose prose is now documented as not a stable format. - **`SubAgentTool::with_turn_hook` and `SubAgentTool::with_async_input_filter`**, mirroring `Agent`. A sub-agent task rejected by an input filter fails the tool call with the reason instead of running. ## 0.20.0 This release is 0.20.0: two constructor behaviours change (see Breaking). ### Breaking - **`ModelConfig::meta` returns `None` for an id the price table does not list.** It used to price every id at Muse Spark 1.1/1.2's standard rates, so `meta("muse-spark-1.2-contributor", ..)` overstated the contributor tier by 12x–75x. `muse-spark-1.1` and `muse-spark-1.2` keep their rates. *Migration:* for any other Meta id, set `config.cost` yourself, or add the id to a user override (`YOAGENT_PRICES` or `prices::global::install_override`) and build the config afterwards. - **Generic first-party constructors return `Some` for a listed id.** `anthropic`, `openai`, `openai_responses`, `google`, `xai`, `groq`, `deepseek`, `mistral`, `zai`, `minimax`, `qwen` and `meta` look `(provider, id)` up in the price table: `ModelConfig::anthropic("claude-sonnet-5", ..)` now carries `claude_sonnet_5()`'s rates instead of `None`, so `session_cost_usd()` and the telemetry `cost_usd` field report a number where they reported nothing. An id the table does not list is still `None`. Gateways and custom endpoints (`custom`, `openai_compat`, `local`, `ollama`, `opencode_zen` / `opencode_go`, `mock`) stay `None` — their bill is not the vendor's list price. This replaces 0.19's "generic constructors are unpriced" rule. *Migration:* code that relied on `cost.is_none()` for a generic constructor should check the id against `PriceTable::builtin()` instead, or set `config.cost = None` after construction to keep a model unpriced. ### Added - **Prices are data: `src/provider/prices.json` and `PriceTable`.** Every rate, tier and caveat that was an `f64` literal in a `ModelConfig` constructor now lives in one JSON file (schema 1, keyed by provider then model id, with `source`, `verified` and `note` per entry), embedded with `include_str!`. Every preset's resulting `CostConfig` is bit-for-bit what 0.19.0 shipped; a new test pins each one. - `PriceTable` (plain data) offers `builtin()`, `from_json_str` / `from_path`, `layered(&over)` (per-model replacement), `cost`, `entry`, `iter`, `len`, `insert`, `changes_from` and `to_json`. - Every entry is validated through `PriceTable::insert` and the public `PriceEntry::validate`: non-empty ids, finite non-negative rates, strictly ascending tier thresholds above 0, a `YYYY-MM-DD` `verified` date, and a `cache_write_at_input` flag the rates agree with. `PriceEntry` has `new` and `with_*` builders, including `with_absent_upstream`. - `PriceError` is `Clone` and `#[non_exhaustive]`, as is each struct variant. `Json`, `Io` and `Http` errors name the file or URL. Transport errors are kept as an opaque source, so no HTTP client type is part of the API. - Unknown fields: data people maintain by hand (`from_json_str`, `from_path`, `YOAGENT_PRICES`, `PriceSource::Url`) is strict — an unknown field is `PriceError::UnknownField`, a typo or data for a newer yoagent. This crate's published file (`PriceSource::YoagentMain`) and caches are lenient: unknown fields are ignored, logged at `warn` and returned to the caller. - The format contract: a field that changes billing bumps `schema`; a metadata-only field does not. A test pins the field set to `PRICE_SCHEMA_VERSION`. - `CostConfig` and `ContextTier` now derive `PartialEq`. - **Runtime price overrides, in `provider::prices::global`.** Constructors read a process-wide table: a user layer over an opt-in fetched layer over the built-in data, each replacing whole entries per `(provider, id)`. - `install_override(table) -> OverrideReport` installs the user layer under one lock. The report (`#[must_use]`) lists the models it changed (`changes`, excluding entries no constructor reads), the models that revert because a previous user layer listed them (`reverted`), entries for providers outside `PRICED_PROVIDERS` (`inert`), and `warnings`. It warns about inert entries, entries that drop the replaced entry's context tiers, cache rates left unset where the replaced entry set them, and replacing an existing user layer (including one from `YOAGENT_PRICES`). `clear_override()` removes the layer; `resolved()` snapshots the table. - `YOAGENT_PRICES=/path/to/prices.json` seeds the user layer, read once on first use; unset or empty means no file. A bad file is logged with `tracing::warn!` and ignored, never a panic. `env_override_status() -> EnvOverride` reports `Unset`, `Loaded { path, entries, warnings }` or `Rejected { path, error }`, and `load_env_override()` reads the file strictly on demand, so a host can fail fast. The crate's unit tests never read the variable, and the test suite passes with it set. - Constructors resolve when they run: install prices before building configs, or re-price afterwards (below). A `cost` set on a config wins over every process-wide layer — but `reprice()` and `with_prices()` overwrite it. - **Re-pricing.** `ModelConfig::reprice()` repeats a first-party constructor's lookup against the current table, and may set `None` when the model is no longer listed. It applies only to a config a pricing constructor built whose `provider` is still in `PRICED_PROVIDERS` (a private, unserialized marker records the first); anything else is returned unchanged. `Agent::reprice()` and `SubAgentTool::reprice()` do the same for the config they hold. `ModelConfig::with_prices(&table)` re-resolves from a table you hold, without global state: listed models get the table's rates, unlisted ones keep theirs (it never clears a price), and it applies to gateways and custom endpoints too. - **Opt-in live price sources.** Nothing is ever fetched implicitly. - `PriceTable::fetch(&source)` and `fetch_with(&source, FetchOptions) -> FetchReport { table, skipped, ignored_fields }` read models.dev (`ModelsDev`, `ModelsDevAt(url)` — mapped into this crate's schema, tiers from `tiers` or `context_over_200k`, provider keys `alibaba` and `opencode` renamed to `qwen` and `opencode-zen`), this crate's checked `prices.json` on GitHub `main` (`YoagentMain`, so a merged price fix reaches users without a release), or a URL in this crate's format (`Url`). `FetchOptions` sets the timeout (default 10 s). - A models.dev model the mapping cannot express is skipped, never approximated, and reported as a `SkippedModel` with the reason (`from_models_dev_json_report` does the same offline). A skipped model the built-in data lists is named in a `warn` log. - `global::install_fetched(table)` installs the fetched layer, above the built-in data and below the user layer; `install_fetched_with(table, InstallPolicy::AddOnly)` instead merges in only models no lower layer lists. Both are `#[must_use]` and return the `PriceChange`s against the previous built-in-plus-fetched table, so reverts show up, marking changes the user layer overrides as `shadowed`. Replacing a non-empty fetched layer is logged at `warn`. Separately, the built-in models the new layer prices differently are logged at `warn` — the first five, and a count of the rest; `changes_from` computes the full list without installing. - `PriceTable::fetch_cached(&source, path, CacheOptions) -> CachedPrices` caches to a file you choose (defaults: refetch after a day, accept a stale cache up to a week old, 10 s timeout). The cache records its source URL; a cache of another source is a miss. `PriceOrigin` carries what fits each origin: `Fetched { cache_write_error }`, `Cache { age }`, `StaleCache { age, fetch_error }` or `Builtin { fetch_error }`. `CachedPrices` also returns the skip list, ignored fields, and any `CacheProblem` (an unreadable or invalid cache, or a source mismatch), each also logged. A cache whose age is unknown (a future modification time) is never fresh, and is a stale fallback only when `max_stale` is `Duration::MAX`. Precedence for what constructors set, highest first: user override > fetched > built-in. - **`tests/price_audit.rs` audits every `prices.json` entry**, not a hand-kept preset list, so an entry cannot be added unaudited. Its allowances are now data (`cache_write_at_input`, `absent_upstream`), and its comparison logic also runs offline in CI against a checked-in slice of models.dev. ## 0.19.0 ### Breaking - **`ThinkingLevel` gains `XHigh` and `Max`, and is now `#[non_exhaustive]`** ([#171](https://github.com/yologdev/yoagent/issues/171)). `High` was the top of the enum, so Anthropic's `xhigh` (Anthropic's recommended setting for coding and agentic work on current models) and `max`, and DeepSeek's `max`, could not be requested at all. Setting the highest level the type offered read as "maximum reasoning" and was not. The ceiling was invisible. Each provider now maps the new levels to its own ladder, and where the ladder is shorter it **clamps** to the highest value this crate knows it accepts instead of sending a value it would reject (Anthropic's adaptive effort excepted, see below). The full table is on `ThinkingLevel`'s doc comment: | Level | Anthropic effort | Anthropic legacy / Bedrock budget | OpenAI-compat¹ / Responses / Azure | DeepSeek² | Gemini / Vertex 2.x budget (3.x: `thinkingLevel`, see Fixed) | |-------|------|------|------|------|------| | `XHigh` | `xhigh` | 16,384 | `xhigh`, or `high` (clamped) below an `XHigh` ceiling³ | `high` (clamped) | 24,576 (clamped) | | `Max` | `max` | 30,720 | the model's ceiling: `max`, `xhigh` or `high`³ | `max` | 24,576 (clamped) | ¹ When `supports_reasoning_effort` is set. ² When both `supports_thinking_control` and `supports_reasoning_effort` are set. DeepSeek maps a requested `xhigh` to `high` itself, so `XHigh` is sent as `high`; only `Max` selects DeepSeek's `max` rung. ³ The ceiling is declared per model with `OpenAiCompat::max_reasoning_effort` (default `high`); see Fixed, [#176](https://github.com/yologdev/yoagent/issues/176). Adding the variants did not change what any existing level sends. (Other entries below do, among them: xAI now receives an effort; Gemini 3 gets `thinkingLevel`; `claude_haiku_4_5()` and OpenCode Claude ids before 4.6 send budget rather than adaptive thinking; and structured calls on the Claude presets keep their thinking.) The new serde names are `"xhigh"` and `"max"` (lowercase, like the others). Persisted configs keep loading because the existing names are unchanged. Anthropic effort is passed through, not clamped: Opus 4.6 / Sonnet 4.6 have no `xhigh` rung (it arrived with Opus 4.7) and will reject it. The crate has no per-model effort table. **Migration:** an exhaustive `match` on `ThinkingLevel` outside this crate no longer compiles. Add arms for `XHigh` and `Max`, or a wildcard arm. The wildcard is required from now on (`#[non_exhaustive]`), so the next rung a vendor adds is not another breaking change. Code that only constructs or compares levels is unaffected. - **`ModelConfig::cost` is now `Option`; `None` means pricing unknown** ([#172](https://github.com/yologdev/yoagent/issues/172)). Sixteen public constructors — `custom`, the generic `anthropic`, `openai`, `local`, `opencode_zen`, `opencode_go`, `openai_compat`, `ollama`, `zai`, `minimax`, `qwen`, `xai`, `groq`, `deepseek`, `mistral` and `google` — plus `mock()`, which delegates to `custom`, shipped `CostConfig::default()`, whose rates are all `0.0`. A consumer reading `config.cost` for DeepSeek got a number indistinguishable from a free model; "do you know this price?" could only be answered by knowing to call `is_configured()`. So consumers kept parallel price tables, outside the models.dev audit, and those rotted: yoyo overstated every `deepseek-v4-flash` cost ~3.7x. Those seventeen now return `None`, as does the new `openai_responses` constructor. The twelve priced presets return `Some`: `claude_fable_5_1`, `claude_fable_5`, `claude_opus_5_5`, `claude_opus_5`, `claude_opus_4_8`, `claude_sonnet_5`, `claude_haiku_4_5`, `gpt_5_5`, `gpt_6_astra`, `gpt_6_sol`, `gpt_6_luna` and `meta` — every one in the price audit (`tests/price_audit.rs`, which now requires at least 12). The generic constructors gained no prices: each one added later should land with a price-audit entry. In memory, `Some` with every rate zero now means **free** (a local model, a free tier): `session_cost_usd()`, `SessionStats::cost_usd`, the `llm_stream` span's `cost_usd` and `LlmCompaction`'s summary cost all report `0.0` for it, and are absent only for `cost: None`. Before, the crate's accounting treated an all-zero config as unknown, so a free model could not be expressed. Serde: a missing or `null` `cost` is `None`, and `None` is omitted on serialize, so older releases still load the output. A persisted `cost` object with **every rate zero** — what every unpriced preset wrote before this release — deserializes to `None`, because reading it as free would report $0 for models that bill. The cost of that: **a free config saved by this release also reloads as `None` (unknown)**, since the two encodings are identical on disk. Reapply `Some(CostConfig::new(0.0, 0.0))` after loading if you persist one. Any non-zero rate, including one only in a context tier, loads as `Some` unchanged. Migration: ```rust // before // after config.cost.input_per_million = 1.80; config.cost.get_or_insert_with(CostConfig::default).input_per_million = 1.80; config.cost = CostConfig::new(0.15, 0.60); config.cost = Some(CostConfig::new(0.15, 0.60)); config.cost.cost_usd(&usage) config.cost.as_ref().map(|c| c.cost_usd(&usage)) if config.cost.is_configured() { .. } if let Some(c) = &config.cost { .. } ``` Do **not** migrate the first row to `if let Some(c) = config.cost.as_mut()`: every generic constructor now returns `None`, so on DeepSeek, `custom`, `openai` and the rest the assignment silently never runs. On a named preset, `get_or_insert_with` edits the existing rates; on a generic constructor it starts from all-zero — output then bills at $0 and cache tokens at the input rate (see Changed) — so set every rate you are billed for, or build the whole thing with `Some(CostConfig::new(..))`. The last row changes meaning for an all-zero config built in memory, which is now free rather than unknown. - **`AnthropicCompat` is now `#[non_exhaustive]` and gains `native_structured_output`** ([#175](https://github.com/yologdev/yoagent/issues/175)). A struct literal no longer compiles outside the crate, the same rule as `OpenAiCompat`: start from `AnthropicCompat::default()`, `AnthropicCompat::legacy()` or `AnthropicCompat::for_claude_id(id)` and set fields, or chain `with_native_structured_output(..)` / `with_bearer_auth(..)`. Serde is unchanged: a persisted config without the new key loads with it off. ```rust // before AnthropicCompat { adaptive_thinking: true, bearer_auth: true } // after AnthropicCompat::default().with_bearer_auth(true) ``` - **`prompt_structured` on the `claude_*` presets uses Anthropic's native JSON outputs** instead of a forced tool call ([#175](https://github.com/yologdev/yoagent/issues/175)). Every Claude preset (`claude_fable_5`, `claude_fable_5_1`, `claude_opus_5_5`, `claude_opus_5`, `claude_opus_4_8`, `claude_sonnet_5`, `claude_haiku_4_5`) now sets `anthropic: Some(..)` with `native_structured_output` on, and so do `opencode_zen("claude-…")` ids from Claude 4.5 on (see Fixed); each of those models is on Anthropic's supported list for `output_config.format`. What changes for a structured call on those configs: - the schema goes out as `output_config.format`, not as a synthetic tool plus `tool_choice`; - thinking is no longer dropped for that request (a set `ThinkingLevel` keeps its thinking alongside the format — an `effort`, or a `budget_tokens` budget on `claude_haiku_4_5()`), so the call can cost more output tokens; - tool choice stays `auto`, so the model may call regular tools before answering; - the API compiles the schema into a grammar and rejects features the tool path tolerated: objects need `"additionalProperties": false`, and numeric and length constraints (`minimum`, `maxLength`, …) are unsupported. A schema that worked before can now fail as `StructuredPromptError::Provider` with a 400. **Migration:** add `"additionalProperties": false` to every object in the schema and drop numeric/length constraints (validate those after parsing), or turn the flag off to keep tool-forcing: `config.anthropic = Some(AnthropicCompat::default())` (or `legacy()` for a pre-4.6 model). Fable 5.1 and Opus 5.5 reject forced `tool_choice`, so on those the flag must stay on. `ModelConfig::anthropic(id, name)` and `AnthropicCompat::default()` leave the flag off, and with it off the request body is byte-identical to before. ### Added - **GPT-6 presets: `ModelConfig::gpt_6_astra()`, `gpt_6_sol()`, `gpt_6_luna()`** ([#176](https://github.com/yologdev/yoagent/issues/176)). There is no bare `gpt-6` id. All three use the **Responses API** (`openai_responses`): OpenAI's Chat Completions "does not support function calling with GPT-6 Astra", and on Sol/Luna allows it "only with reasoning_effort set to none", so an agent with tools needs Responses. 1,050,000-token context, 64K of 128K max output, effort ceiling `max` (`ReasoningEffortCeiling::Max`). `ThinkingLevel::Off` omits the effort, so the model runs at its default; the crate never sends `none` (Astra rejects it with a 400). `temperature` is rejected while the effort is not `none` — leave it unset. The Responses provider does not enforce `prompt_structured` schemas (it warns and ignores them), so structured outputs are not guaranteed on these presets. Priced per 1M tokens, with a 272K context tier (the whole request moves to the long rates): | Preset | Input | Cached | Cache write | Output | >272K in / cached / write / out | |--------|------:|------:|------:|------:|------| | `gpt_6_astra` | $10 | $1 | $12.50 | $50 | $20 / $2 / $25 / $75 | | `gpt_6_sol` | $2 | $0.20 | $2.50 | $10 | $4 / $0.40 / $5 / $15 | | `gpt_6_luna` | $0.10 | $0.01 | $0.125 | $0.50 | $0.20 / $0.02 / $0.25 / $0.75 | Read from the raw markup of OpenAI's pricing and model pages on 2026-09-25; added to the price audit, which now compares context tiers field by field. - **`OpenAiCompat::max_reasoning_effort` (`ReasoningEffortCeiling`)** — see Fixed ([#176](https://github.com/yologdev/yoagent/issues/176)). - **`ModelConfig::openai_responses(id, name)`** ([#178](https://github.com/yologdev/yoagent/issues/178)). `ModelConfig::openai()` targets Chat Completions, and there was no first-party constructor for OpenAI's Responses API. The new one sets `ApiProtocol::OpenAiResponses`, `https://api.openai.com/v1`, provider `openai` (key from `OPENAI_API_KEY`), `reasoning: true` and `cost: None`; `Agent::from_config` resolves it to `OpenAiResponsesProvider`. - **`ModelConfig::claude_fable_5_1()`** ([#170](https://github.com/yologdev/yoagent/issues/170)). 1M context, 64K of 128K max output, $10 / $50 per MTok input/output, $12.50 5-minute cache writes, and **$0.25 cache hits** — 0.025x input, against 0.1x ($1.00) on Fable 5. That is the only rate that differs, and it is the one that dominates an agent loop's bill: a consumer mapping `claude-fable-5-1` onto `claude_fable_5()` by prefix, which is the natural thing to do with no 5.1 preset, reported cache reads 4x high. Rates read from the raw markup of Anthropic's pricing page on 2026-09-24; added to the price audit. Fable 5.1 rejects forced `tool_choice` (`any`/`tool`) with a 400. The preset sets `native_structured_output`, so `prompt_structured` works on it (see Fixed, [#175](https://github.com/yologdev/yoagent/issues/175)). - **`ModelConfig::claude_opus_5_5()`** ([#175](https://github.com/yologdev/yoagent/issues/175)). 1M context, 64K of 128K max output, $4 / $20 per MTok input/output, $5 5-minute cache writes, and **$0.20 cache hits** (0.05x input, not the usual 0.1x). The 1-hour write rate ($8) is not modelled, since the crate only places 5-minute breakpoints. Rates read from the raw markup of Anthropic's pricing page on 2026-09-25 and matched against models.dev; added to the price audit, `llm_compaction_live`'s id map and `release_smoke` (`SMOKE_MODEL=opus55`). Not a drop-in for Opus 5. Thinking is always on: `ThinkingLevel::Off` omits the field, and the model still thinks at its default effort, `medium`. The API rejects forced `tool_choice` (the preset uses native structured outputs, so `prompt_structured` works), and rejects non-default `temperature`/`top_p`/ `top_k`. Thinking blocks are bound to the model and the conversation prefix. - **`AnthropicCompat::native_structured_output`** and `AnthropicCompat::with_native_structured_output` (see Breaking). - **`AnthropicCompat::for_claude_id(id)`** infers compat flags from a Claude model id: budget thinking (`legacy()`) before Claude 4.6, adaptive from 4.6, and `native_structured_output` from 4.5; an id with no recognizable version is treated as current generation. A version token is read by its leading digits, so suffixed ids (`claude-opus-4-6[1m]`, `claude-sonnet-4-5@20250929`, `claude-opus-4-6-v1:0`) keep their minor version; an 8-digit date is never a minor. **`AnthropicCompat::with_bearer_auth(on)`** sets `bearer_auth` without a struct literal. - **`ModelConfig::google: Option`** with `GoogleCompat::force_thinking_level()` / `force_thinking_budget()`, to override which Gemini thinking field is sent (see Fixed, Gemini 3). - **`MockResponse::ToolCallsWithUsage` and `MockResponse::ErrorWithUsage`**, so tests can bill a tool-calling turn and a mid-stream failure. ### Changed - **`MockProvider` panics on a transcript a real provider would reject** ([#160](https://github.com/yologdev/yoagent/pull/160)). It used to accept any message sequence, so a test could pass on a history — an orphan tool call, a tool result with no matching call — that every real provider returns a 400 for. It now panics with the offending messages. **Migration:** a test that builds a malformed history on purpose should use `MockProvider::without_transcript_validation()` (added) and say why at the call site. - **Behaviour change: a cache rate left at `0.0` bills at the input rate.** `CostConfig::cost_usd` used to bill cache-read / cache-write tokens at a zero rate as free, so a forgotten cache rate silently under-reported. A zero `cache_read_per_million` / `cache_write_per_million` now means "not separately priced" and bills at the applicable input rate: the base `input_per_million`, or inside a `ContextTier` that tier's own input rate (never the base cache rate). A vendor with no cache-write charge is now priced correctly without setting one. A fully zero config still costs $0, so "free" keeps working. `tests/price_audit.rs` accepts a crate cache-write rate that models.dev omits only while it equals the band's input rate. **Migration:** a config that set a cache rate to `0.0` to mean "these tokens are free" while charging for input now reports a higher cost. Zero can no longer express that; set a tiny positive rate (e.g. `with_cache_read(1e-9)`) if a vendor genuinely charges nothing for cache hits. Configs that leave cache rates unset on a vendor that does charge for them now report closer to the real bill, but should still set the real rate. - **`gpt_5_5()` and `meta()` state their cache-write rate explicitly** as the input rate ($5, and $10 above 272K, for GPT-5.5; $1.25 for Muse Spark): neither vendor charges extra for cache writes. Under the old zero-is-free rule those tokens billed at $0. - **`ModelConfig::gpt_5_5()` bills the long-context band.** OpenAI's gpt-5.5 model page: "prompts with >272K input tokens are priced at 2x input and 1.5x output for the full session". The preset was flat at $5/$30 and now carries a `ContextTier` above 272,000 prompt tokens at $10 input / $45 output, so a long-context request is no longer under-billed by half on input. The long-band **cached-input rate, $1.00, is unverified**: the page names input and output only, and the pricing page no longer lists gpt-5.5. It matches models.dev and the 2x the GPT-6 pages state for cache rates; the literal reading would keep $0.50. It is set rather than left out because an unset tier cache rate bills at the tier's input rate ($10). The old "deliberately flat" rationale — no gpt-5.5 row in the pricing page's long-context table — is superseded by the model page. - **A clamped reasoning effort is logged.** When `XHigh` / `Max` is sent lower than requested because of the model's `max_reasoning_effort` (`XHigh` → `high`, `Max` → `high` or `xhigh`), the Chat Completions, Responses and Azure providers log one `tracing::warn!` per distinct clamp per model id per process, naming the model, instead of downgrading silently. - **`SessionStats::cost_usd` is now sticky-`None` once a turn cannot be priced**, the same rule as `SubAgentSpend::merge`. Previously an unpriced turn followed by a priced one produced a partial sum that read as the whole cost. Only reachable when rates change mid-run, which the built-in loop does not do today. - **`ModelConfig::minimax` now points at `https://api.minimax.io/v1`** instead of `https://api.minimaxi.chat/v1`. MiniMax's OpenAI-compatible API reference names `api.minimax.io/v1` as the base URL. Configs that override `base_url` are unaffected; set it back to the old host if your key is tied to it. - **Examples no longer default to retired model ids.** `release_smoke` (`gemini-3-pro` → `gemini-3.1-pro-preview`, `deepseek-chat` → `deepseek-flash`), `long_horizon` (`deepseek-chat` → `deepseek-flash`), `rlm` (`grok-4-1-fast-reasoning` → `grok-4.7`) and the `cli` per-provider defaults (xAI `grok-4.7`, Groq `openai/gpt-oss-120b`, DeepSeek `deepseek-flash`, MiniMax `MiniMax-M3`, Google `gemini-3.8-flash`). `llm_compaction_live` prices `deepseek-flash` and corrects the Flash peak rates to $0.30 / $1.20 / $0.006 cache hit per million. ### Fixed - **Truncated tool arguments no longer run the tool on `{}`** ([#167](https://github.com/yologdev/yoagent/issues/167)). When a response hit its output token limit mid-`arguments`, `openai_compat.rs` (OpenAI, Groq, Together, DeepSeek, Fireworks, Mistral, xAI, …) fell back to an empty object and warned, so `delete_files({"paths":` became `delete_files({})` — the tool ran on its own defaults and the model saw a plausible result. `openai_responses.rs` and `azure_openai.rs` did the same without even the warning. A **non-empty** argument buffer that does not parse is now kept as `{"__partial_json": ""}` (`provider::parse_tool_arguments`), and `execute_single_tool` — the choke point every execution strategy shares — answers such a call with an error tool result instead of running it. When the text ended early, the arguments "were cut off before they were complete (the response likely hit the output token limit)… The tool was not run. Do not resend the same call unchanged — make the arguments smaller"; it is deliberately *not* told to retry, since the same call would be cut off at the same point again. Complete but malformed JSON (a trailing comma, two objects run together) is answered "not valid JSON… Send the arguments as a single valid JSON object" instead. Either way the loop continues so the model can adapt. The check runs before middleware, so `ToolMiddleware` never sees such a call. `provider::unparsed_tool_arguments` recognizes the marker. **Valid JSON that is not an object** is refused the same way. `null` resolves to `{}` like an empty buffer; a string, number, array or boolean is marked, and the error says the arguments "were not a JSON object (got a string)". The motivating case is double-encoded arguments, `"{\"path\":\"src\"}"`, which parsed to a JSON string — `list_files` then read `path` as missing and silently listed `.`. An **empty** buffer is unchanged: a zero-argument tool streams `""`, which still resolves to `{}` and runs. That distinction is what the Anthropic provider once got wrong, and both cases are now pinned together in one loop test. **`StopReason::Length` is no longer overwritten.** These three providers relabelled any turn that carried a tool call as `ToolUse`, hiding a `finish_reason: "length"` / `response.incomplete` — the very turns that produce cut-off arguments. Such a turn now reports `Length`; the loop still extracts and answers its tool calls (it only stops early on `Error` and `Aborted`). The marker key is the one the Anthropic provider already used as its streaming accumulator, now a shared `UNPARSED_ARGUMENTS_KEY` constant. Anthropic's behaviour is **unchanged** — it still fails the turn when an accumulator is left unparsed — but the loop's guard now backs it up. Google and Vertex receive arguments as already-parsed JSON objects, so they have no unparseable-buffer case and are unchanged. Bedrock is also unchanged here: its stream parsing needs a larger fix, tracked in [#174](https://github.com/yologdev/yoagent/issues/174). - **`ThinkingLevel::XHigh` / `Max` no longer clamp to `high` on OpenAI models that accept more** ([#176](https://github.com/yologdev/yoagent/issues/176)). The clamp was there because nothing recorded which models take `xhigh`; now a model declares it. `OpenAiCompat::max_reasoning_effort` is a `ReasoningEffortCeiling` (`High` / `XHigh` / `Max`, `#[non_exhaustive]`, serde default `High`): `XHigh` sends `xhigh` when the ceiling is at least `XHigh`; `Max` sends `max`, `xhigh` or `high` — whatever the ceiling is. It is a declared capability, never inferred from the model id. Honoured by Chat Completions **and** by the Responses and Azure providers, which until now ignored `ModelConfig::compat` entirely; they read this one field from it and nothing else, so a Responses config opts in the same way a Chat Completions one does. Per OpenAI and Azure: `max` exists on GPT-5.6 and GPT-6; `xhigh` on GPT-6, GPT-5.6, GPT-5.5, GPT-5.4 (and gpt-5.2, gpt-5.1-codex-max); gpt-5 and gpt-5.1 top out at `high`. | Preset | Ceiling | `XHigh` → | `Max` → | |--------|---------|-----------|---------| | `gpt_6_astra`, `gpt_6_sol`, `gpt_6_luna` | `Max` | `xhigh` | `max` | | `gpt_5_5` | `XHigh` | `xhigh` | `xhigh` | | `OpenAiCompat::xai()` | `XHigh` | `xhigh` | `xhigh` | | everything else | `High` | `high` | `high` | `ThinkingLevel::Off` still omits the effort on every model — the model runs at its default (`medium` on OpenAI reasoning models), not without reasoning. The crate never sends OpenAI's `none` rung, which several models (GPT-6 Astra, gpt-5, the o-series) reject with a 400. (A `supports_effort_none` flag existed briefly during development and was removed before release; a persisted `OpenAiCompat` carrying that key still loads and the key is ignored.) xAI gets `XHigh` on every Grok model: xAI documents that models without `xhigh` treat it as `high` rather than rejecting it. DeepSeek's `low`/`high`/`max` ladder (`supports_thinking_control`) is unchanged and does not read the new field. Every other existing preset sends byte-identical bodies (whole-body tests across all levels); persisted `OpenAiCompat` values without the field load with the old behaviour. - **xAI (Grok) now receives `ThinkingLevel`.** `OpenAiCompat::xai()` did not set `supports_reasoning_effort`, so every level was silently dropped and Grok always ran at its default. It now sends `reasoning_effort` like other OpenAI-compat providers: `Minimal`/`Low` → `low`, `Medium` → `medium`, `High` → `high`, `XHigh`/`Max` → `xhigh` (ceiling `XHigh`, above; grok-4.6 and later accept `xhigh`, and earlier models treat it as `high`). xAI reasoning cannot be disabled, so `Off` still sends nothing and leaves the model at its default (`high`), not "no reasoning" ([xAI reasoning guide](https://docs.x.ai/developers/model-capabilities/text/reasoning)). - **Gemini 3 gets `thinkingLevel`, not a token budget.** Both Gemini providers sent `thinkingConfig.thinkingBudget` for every model. Google's REST reference says `thinkingLevel` is "Recommended for Gemini 3 or later models", and its guide warns that a budget on Gemini 3 Pro "may result in unexpected performance". For Gemini 3 and later, `GoogleProvider` and `GoogleVertexProvider` now send `thinkingLevel` (`Minimal` → `MINIMAL`, `Low` → `LOW`, `Medium` → `MEDIUM`, `High`/`XHigh`/`Max` → `HIGH`) and never a budget alongside it, which Gemini rejects. `Minimal` is clamped to `LOW` on models without a `MINIMAL` rung (3.7 / 3.8 Flash reject it with an error; 3.x Pro and unlisted models are treated the same way). Image models take only the levels Google's Vertex thinking table lists: 3.1 Flash Image / 3.1 Flash-Lite Image accept `MINIMAL` and `HIGH` (`Minimal`/`Low` → `MINIMAL`, `Medium` and up → `HIGH`); 3 Pro Image accepts `HIGH` only. Gemini 3 TTS models are in neither thinking guide's list of thinking models, so they get no `thinkingConfig` at any level. **`ThinkingLevel::Off` still omits `thinkingConfig` on every Gemini model.** On Gemini 3 that does not disable thinking: the model runs at its own default level (3.1 Pro `HIGH`, 3.5–3.8 Flash `MEDIUM`, Flash-Lite `MINIMAL`), and 3 Pro / 3.1 Pro cannot turn thinking off at all. Use `ThinkingLevel::Minimal` to ask for the least thinking — `MINIMAL` "Matches the "no thinking" setting for most queries" (Google) where the model accepts it. **Gemini 2.x payloads are byte-identical** to before: `thinkingLevel` on a pre-3 model is an error, so those keep `thinkingBudget`. Detection is version-based, as Google's rule is: the last segment of the model id (so `models/…` and Vertex resource paths work), `@version` dropped, `gemini-…` with major ≥ 3. `gemini-flash-latest` / `gemini-pro-latest` count as Gemini 3; `gemini-flash-lite-latest` and unreadable ids keep the budget, which Gemini 3 still accepts. Override per model with the new `ModelConfig::google: Option` — `GoogleCompat::force_thinking_level()` / `force_thinking_budget()`. `None` is omitted on serialize, so existing serialized configs are unchanged. - **`prompt_structured` works on Claude Fable 5.1 and Opus 5.5** ([#175](https://github.com/yologdev/yoagent/issues/175)). Both models reject forced `tool_choice` with a 400, and the Anthropic provider forced a synthetic tool for every structured call, so every call failed as `StructuredPromptError::Provider`. With `native_structured_output` set (the presets set it) the provider sends `output_config.format = {"type": "json_schema", "schema": ...}` and no tool or `tool_choice`, and thinking is kept: `format` and the adaptive-thinking `effort` share one `output_config` object. A hand-built `ModelConfig::anthropic("claude-fable-5-1", ..)` without the flag still forces the tool and gets the 400. - **On the native structured-output path, a user tool named like the schema runs.** The loop unwrapped any tool call named after the `OutputSchema` into the answer text. That is right when the provider forced a synthetic tool, but on Anthropic's native path no synthetic tool exists, so a real tool that happened to share the name (e.g. `structured_output`) was swallowed instead of executed. The unwrap now runs only on the tool-forcing path. (Only the native path added in this release was affected.) - **`claude_haiku_4_5()` uses budget thinking.** The preset inherited adaptive thinking (`AnthropicCompat::default()`), which Haiku 4.5 rejects with a 400, so any `ThinkingLevel` other than `Off` failed. It now uses `AnthropicCompat::legacy()` (`budget_tokens`), with native structured outputs on. - **OpenCode `claude-*` routes get per-generation compat.** `opencode_zen` gave every Claude id adaptive thinking and bearer auth and nothing else, so a pre-4.6 model (Haiku 4.5, Sonnet/Opus 4.5 and older) 400ed on any thinking level, and `prompt_structured` tool-forced and 400ed on Opus 5.5 / Fable 5.1. Claude ids now get `AnthropicCompat::for_claude_id` (budget thinking before 4.6, adaptive from 4.6, native structured outputs from 4.5) plus bearer auth. Qwen and MiniMax routes are unchanged. `jev-*` ids, which OpenCode serves on its `/systemone` endpoint that yoagent does not target, now log a warning like `gemini-*` ids do. - **OpenCode routes `grok-*` and `muse-spark-*` (Zen and Go) and `gpt-*` (Go) to the Responses API.** The gateways' endpoint tables list those families only on `/responses`, but `opencode_zen` / `opencode_go` sent them to `/chat/completions`. Zen's `gpt-*` routing was already right. - **Azure OpenAI requests go to the v1 endpoint.** The provider posted to `{base_url}/responses?api-version=2025-01-01-preview` with a deployment-scoped `base_url`, a path where Azure never served the Responses API, so requests failed (tests passed because the mocks matched any path). Requests now go to `POST {resource}/openai/v1/responses` with no `api-version`, the deployment name as the body's `model`. `base_url` may be the resource endpoint, `…/openai` or `…/openai/v1` (`*.services.ai.azure.com` too). An empty API key no longer sends an empty `api-key` header, so a Microsoft Entra ID `Authorization: Bearer` token set in `ModelConfig::headers` works on its own. **Migration:** a legacy `…/openai/deployments/{deployment}` `base_url` still works — it is routed to `/openai/v1/responses` and `{deployment}` replaces the configured model id in the body (logged at `info`). A query string or fragment on `base_url` (`…/deployments/my-gpt?api-version=2024-10-21`) is dropped before the path is read, so it no longer ends up in the deployment name. New configs should use `https://{resource}.openai.azure.com/openai/v1` with the **deployment name** as the model id. Any other `base_url` (a proxy path, say) now gets `/openai/v1/responses` appended rather than `/responses?api-version=…`; point a proxy at a path that forwards that. - **Responses and Azure OpenAI: function calls are collected again** ([#178](https://github.com/yologdev/yoagent/issues/178)). Both providers opened a tool-call buffer only on `response.function_call_arguments.start`, an event the Responses API does not send, so every streamed function call was dropped and the turn ended as plain text. The two providers now share one parser written against OpenAI's published event types: a call starts on `response.output_item.added` (`item.type == "function_call"`), argument deltas are routed by `output_index` / `item_id` (parallel calls no longer share "the last buffer"), and the complete arguments from `response.function_call_arguments.done` / `response.output_item.done` are the source of truth, which also covers servers that send no deltas. Truncated arguments still become the `__partial_json` marker and `response.incomplete` still reports `StopReason::Length` (#167). A delta for an unknown item is logged with `warn!`, not dropped silently. Data-only SSE messages take their event name from the payload's `type`. - **Responses and Azure OpenAI: reasoning is streamed.** The providers matched `response.reasoning.delta`, which does not exist. They now map `response.reasoning_summary_text.delta` and `response.reasoning_text.delta` to `Content::Thinking` (Azure previously ignored reasoning entirely). - **Responses and Azure OpenAI: cache usage is reported.** `usage` read only input and output tokens. `input_tokens_details.cached_tokens` and `.cache_write_tokens` now fill `Usage::cache_read` / `cache_write`, and `Usage::input` is the uncached remainder (the Anthropic and OpenAI-compatible convention), so `CostConfig` prices cache hits and cache writes at their own rates. Previously every cached token was billed at the full input rate. Context-tier selection still sees the whole prompt. - **Azure OpenAI** now reports `StopReason::Length` when `response.completed` carries `status: "incomplete"`, as the Responses provider already did. - **Refusals and content-filter stops are reported as `StopReason::Refusal`.** The shared Responses parser (OpenAI Responses and Azure) dropped refusals: `response.refusal.delta` / `.done` were ignored and a finished message kept only `output_text` parts. A refusal — streamed, in a `content_part.done`, or in the finished `message` item — now sets `StopReason::Refusal`, keeps the refusal text as the turn's text (deduplicated), and is never relabelled `ToolUse`. `incomplete_details.reason == "content_filter"` (on `response.incomplete` or an incomplete `response.completed`) is `Refusal` too; every other reason stays `Length`. On Chat Completions, `finish_reason: "content_filter"` is `Refusal` (it was `Stop`), and an open tool call no longer relabels it `ToolUse`; a model refusal streamed in `delta.refusal` (then `finish_reason: "stop"`) was ignored entirely and is now the turn's text, streamed as text deltas, with `StopReason::Refusal`. All three providers set the assistant message's `error_message` to say why (Chat and Responses share the wording "Request declined by the model (refusal): …"). `Agent::prompt_structured` returns `StructuredPromptError::Provider` with that explanation for a refusal, instead of a `Parse` error over the refusal text (or `NoOutput` when a filter left no text). - **Tools no longer run after a refusal.** The loop stopped only on `Error` / `Aborted`, so a tool call in a response that ended as `StopReason::Refusal` — complete, say, before a content filter cut in — was executed and the run continued. A refusal is now terminal: every tool call in it is answered with an error tool result ("Tool call not run: the response was stopped as a refusal …", with the usual `ToolExecutionStart`/`End` events) without being executed, and the run ends with `TurnEnd` / `AgentEnd` and no further LLM turn, as after `Error` / `Aborted` — queued steering and follow-up messages stay queued. Applies to every `ToolExecutionStrategy`. - **A `null` usage count no longer drops the usage.** Some servers send explicit `null` token counts; `#[serde(default)]` covers only a missing key, so one `null` failed the whole Chat Completions chunk or Responses terminal event — losing the usage and the stop reason carried with it. A `null` count now reads as 0, and the first one in the process is logged at `warn`, since the turn's tokens and cost are then undercounted. - **Rate limits are no longer mistaken for context overflow.** Azure OpenAI's mid-stream capacity error (`too_many_requests` / `no_capacity`, "…exceeds the maximum usage size allowed during peak load…") matched the Gemini overflow phrase "exceeds the maximum", so it triggered compaction instead of a backoff retry. HTTP 429 is now classified `RateLimited` before the overflow phrases are checked, and an SSE error event whose structured `type` / `code` / `status` is `too_many_requests`, `no_capacity`, `rate_limit_exceeded`, `rate_limit_error` or `rate_limit` is `RateLimited` (retried). The Gemini phrase is narrowed to "exceeds the maximum number of tokens", matching Gemini's actual overflow message. - **DeepSeek thinking mode with tools no longer fails on the second turn.** DeepSeek requires that "for requests carrying the tools parameter, the reasoning_content must be fully passed back to the API in all subsequent requests — even for turns where the model did not perform a tool call", and answers a request that does not with a 400 ([thinking mode guide](https://api-docs.deepseek.com/guides/thinking_mode)). The OpenAI-compat provider dropped `Content::Thinking` when rebuilding assistant messages, so any tool-using DeepSeek agent with thinking on broke after its first tool call. Assistant turns now carry their thinking as `reasoning_content` (multiple thinking blocks are concatenated) when the request has tools. Without tools DeepSeek ignores the field, so it is not sent. Gated on a new `OpenAiCompat::replays_reasoning_content` flag, set only by `OpenAiCompat::deepseek()`; every other provider's request body is byte-identical to before. A DeepSeek compat persisted before this flag deserializes it as `false`, so rebuild it from the preset. - **Chat Completions usage now reports cache writes.** `prompt_tokens_details. cache_write_tokens` (GPT-5.6 and later bill cache writes at 1.25x input) was ignored, so `Usage::cache_write` stayed 0 and those tokens were counted as ordinary `input`. It is now parsed into `cache_write` and excluded from `input`, alongside `cached_tokens` — OpenAI's [prompt-caching guide](https://developers.openai.com/api/docs/guides/prompt-caching) computes ordinary input as `input_tokens - cached_tokens - cache_write_tokens`. Responses without the field split exactly as before. - **Sub-agent spend now reaches the parent, in its own bucket** ([#173](https://github.com/yologdev/yoagent/issues/173)). `SubAgentTool` ran its child loop on a private channel and forwarded no usage, so every total a delegating agent reported — `SessionStats`, `session_cost_usd`, anything a consumer summed off the stream — silently left out what its sub-agents spent. The error only ran one way (always under), nothing distinguished a parent that delegated from one that did not, and nesting hid a whole tree of spend behind one number. `SessionStats` gains `sub_agents: SubAgentSpend` — `usage`, `cost_usd` and `runs`, summed over the **whole delegation tree** (a sub-agent's own sub-agents included). It is a separate bucket: `usage`, `turns` and `cost_usd` stay the agent's own, so delegation stays attributable. `SessionStats::total_usage()` / `total_cost_usd()` give the whole bill. - **Per delegation**, `ToolExecutionEnd` carries the sub-agent's full `SessionStats` in `details`; read it with `SessionStats::from_sub_agent_result(&result)`. Its `usage` is the sub-agent's own and its `sub_agents` what it delegated, so own and nested spend stay distinguishable. A tool call that reported several runs carries their combination (own figures summed, nested buckets merged). - **Failed delegations count.** A sub-agent that errors or is loop-aborted returns `Err(ToolError)`, which cannot carry data, so it reports through a side channel on `ToolContext`; the loop folds it in and attaches the stats to the error result's `details`. - **Custom delegation tools** report the same way with `ToolContext::report_delegated_run(stats)` — once per run, before `execute` returns (a report after that is lost). `SubAgentTool` uses it too. - **Each run is priced at its own model's rates** — a sub-agent on a cheaper model is never re-priced at the parent's. `cost_usd` becomes `None` as soon as any delegated spend is unpriceable, rather than a sum that quietly skips it; `SubAgentSpend::merge` keeps that rule for callers accumulating across runs. `None` therefore means "unpriced" *or* "nothing spent and nothing priced" (a priced run that used no tokens is `Some(0.0)`); `SubAgentSpend::is_unpriced()` / `SessionStats::is_unpriced()` tell them apart. - **`Agent::total_cost_usd()` / `Agent::total_usage()`** give the whole bill — own turns plus every sub-agent — so callers never add two `Option`s by hand (both `zip` and `unwrap_or(0.0)` are wrong in some case). They and **`Agent::sub_agent_spend()`** share one window: every run since construction or `reset()`, accumulated from each run's stats. Clearing, replacing or compacting history does not lower them. `session_cost_usd()` is unchanged and stays history-derived — it prices the current context at the current model's rates, excludes sub-agents, and is not the bill. - Usage and run counts in the spend rollups saturate instead of overflowing. Additive on the wire: `subAgents` is omitted when nothing was delegated, so a run without sub-agents serializes exactly as before. ## 0.18.1 ### Changed - **Fixed: summarization retries panicked in debug builds, silently.** `RetryConfig::delay_for_attempt` documents a 1-indexed attempt and computes `attempt - 1`; `llm_compaction.rs` passed the raw `0..=max_retries` loop variable, so the first retry underflowed `usize`. `agent_loop.rs` increments before calling and was correct — this was confined to compaction. It landed on a **detached** task, so nothing surfaced: the summarization simply vanished, no briefing arrived, and compaction fell back deterministically. That is one of the behaviours [#150](https://github.com/yologdev/yoagent/issues/150) was filed about. `delay_for_attempt` now uses `saturating_sub`, so a caller that misses the 1-indexed contract loses the backoff rather than the task. `provider_failure_falls_back_deterministically` had been passing *because* of this panic: its `FailingProvider` returns a retryable error, and the instant death released the in-flight slot inside the test's 100ms window. With retries working the first backoff is ~1s, so the test now configures no retries — it is about the drop guard, not about backoff timing. - **`LlmCompaction` says so when briefings keep losing the race** ([#150](https://github.com/yologdev/yoagent/issues/150)). A session whose compactions all take the deterministic path gets `DefaultCompaction`'s retention while still issuing summarization requests it never splices — paying input tokens for the span and output tokens for briefings it discards. After **five** consecutive fallbacks it warns once, naming the likely cause, and a splice that lands both resets the streak and clears the latch. Five, not two: a session measured at seven splices in nine compactions — a 78% success rate — still contained a run of two, and a two-in-a-row threshold reported it as broken for the rest of the run. A signal that cannot retract goes stale the moment the configuration improves. It is also gated on a request having actually been issued, and on the briefing surviving into the result. Without the first it fired in the *inert* configuration — where `choose_cut` never finds a split and nothing is ever spawned — claiming a cost of zero tokens, in the same session as `warn_inert_once` giving the opposite `trigger_ratio` advice. Without the second, the "briefing produced, then discarded as too large" path reset the streak rather than counting it, so alternating it with a plain miss sawtoothed the counter and it could never reach the threshold. - **A briefing rejected on fingerprint mismatch now reports what it cost.** The event carried `summary: None`, so a caller doing cost accounting off `SummaryStats` under-counted exactly the failure mode that wastes the most. `types.rs` states the contract plainly — "the request was still paid for, so the event still reports it" — and the sibling discard branch already honoured it. Not one wasted request per fallback: `arm` starts no second request while one is in flight, so a very slow summarizer costs fewer requests than fallbacks — measured at 19 fallbacks against 7 billed requests. The waste is worst in the middle regime, where the briefing lands but always just too late. The cause is a documentation problem, not a logic one: reusing the loop's `ModelConfig` is the obvious call and, for a slow loop model, the worst one — `compact` then finds no briefing ready. (It is *not* that the fallback invalidates a pending summary; `arm` fingerprints the history `compact` is about to return, never the one it received, precisely so that cannot happen.) Measured on the `long_horizon` harness at a 30K configured budget, **one run each — the model is not deterministic, so read these as the shape of the effect, not calibrated figures**. Both rows are the harness's `[compaction #1]` line, and the runs differ only in the summarizer: | summarizer | first compaction | history retained | |---|---|---| | the loop's model (Sonnet 5) | `Deterministic` | 3 msgs / 1.7K tokens | | a fast model (Haiku 4.5) | `Summarized` | 22 msgs / 16.7K tokens | The warning is gated on a request having actually been issued. Without that it also fired in the *inert* configuration — where `choose_cut` never finds a split and nothing is ever spawned — telling the caller they were paying for briefings having spent nothing, in the same session as `warn_inert_once` giving the opposite `trigger_ratio` advice. **Still open on #150:** `compact_headroom_turns` defaults to `Some(30)`, so any session growing faster than ~2.8% of its budget per turn pins `effective_target_ratio` to its `MIN_HEADROOM_RATIO` floor of 0.15 — about 740 tokens/turn at this harness's 26K effective budget, about 2.7K at the 96K default. That is the aggression that makes compaction destructive. Changing the constant alters compaction for every user and wants measurement across growth rates first, not a guess. ## 0.18.0 ### Added - **Fixed: tools with no parameters were uncallable on Anthropic.** A tool whose schema takes no arguments has no JSON to stream, and Anthropic still emits an `input_json_delta` carrying `""`. `serde_json::from_str("")` fails with "EOF while parsing a value", so the `__partial_json` sentinel survived `content_block_stop` and the post-stream sweep failed the whole turn with *"tool call(s) with unusable arguments, not executed"*. Every no-argument tool — `get_status`, `list_files`, `read_log` — was affected. An empty accumulator is an empty argument object, not malformed input. The decision is now a small pure function, `resolve_tool_arguments`, so it has a regression test; genuinely truncated JSON still fails, which is what the sentinel exists for. Found by `examples/release_smoke.rs` against a live provider, not by the suite: `MockProvider` never streams SSE, so nothing exercised the tool-call accumulator at all. - **`examples/release_smoke.rs` — a pre-release check against a live provider.** All 618 unit tests use `MockProvider`, which accepts any message sequence handed to it. That is structural, not an oversight, and it is where this release's worst bug lived: loop detection injected a message between an assistant's `tool_use` blocks and their `tool_result`s, which every real provider rejects, and the suite stayed green. Four checks, chosen for what a mock cannot verify: that loop detection produces a transcript the provider accepts *and leaves the agent usable for a second prompt*; that the model follows a truncation marker and retrieves content head+tail truncation cannot contain; that cost comes from real usage; and that a stable system prompt still produces cache reads. ``` ANTHROPIC_API_KEY=... cargo run --example release_smoke SMOKE_MODEL=deepseek DEEPSEEK_API_KEY=... cargo run --example release_smoke ``` Verified 6/6 on Sonnet 5 and 5/5 on DeepSeek (its config is unpriced, so the cost check reports n/a rather than failing). Exits non-zero on failure. - **Stash eviction protects caller-owned keys; the default backend no longer wedges** ([#144](https://github.com/yologdev/yoagent/issues/144)). `MemoryBackend` — what `SharedState::new()` returns, and the documented path for `Agent::with_shared_state` — rejected rather than evicted, and nothing ever removed `tool-out-*`. At ~300KB per stashed build or grep output a 10MB cap holds ~33 results, after which **every** write failed for the rest of the run, including the model's own `shared_state set`, with the bytes never reclaimed. It now evicts stashed output, oldest first. `FileBackend` had the opposite bug: it evicted whatever was oldest, including keys a caller had set through the backend's own API. A parent that stored a `plan` got `None` back later with no error path anywhere. Both now draw the same line. **Stash entries are evictable; caller keys are not.** Losing a stash entry degrades a marker to an ordinary "key not found" the agent can act on, and the head+tail is still in the transcript — nothing regenerates a caller's artifact. When only caller keys remain, the write reports capacity instead of destroying data. Every eviction now logs; a successful one was previously silent, which mattered because the verify-after-store loop only checks the keys written for the current result and cannot see that this write evicted an earlier one. The line is drawn scope-aware: a sub-agent's stash lands as `scope\u{1f}tool-out-…`, so a whole-key prefix test would read it as caller-owned and never evict it — re-creating the wedge for every delegating run and starving the parent's keys instead. - **A looping sub-agent no longer reports success to its parent** ([#146](https://github.com/yologdev/yoagent/issues/146)). `extract_error` matched only `StopReason::Error`, but a loop abort leaves `ToolUse` on the last assistant message, and `extract_final_text` scans assistant messages only — so it never saw the trailing `[Agent stopped: …]` and fell through to `"(sub-agent produced no text output)"`. The parent received `is_error: false`. A sub-agent that burned its entire budget looping was indistinguishable from one that had nothing to say. The marker prefix is now a public constant, `agent_loop::AGENT_STOPPED_PREFIX`, so recognising a self-stopped run does not mean matching a magic string. Also: `FileBackend::set` returned `Err` for a value already written to disk — `evict_to_fit` propagates `read_dir`/`next_entry` errors, both of which run *after* the write, and the loop's error arm never records the key so rollback could not reach it. It now unlinks before propagating. And a failed rollback is logged rather than discarded. - **Loop detection: accurate name, bounded state, and the coverage it lacked** ([#145](https://github.com/yologdev/yoagent/issues/145)). `max_identical_tool_calls` is now `max_consecutive_identical_tool_calls`. The implementation caps consecutive-run length only, so an alternating `[a, b, a, b, …]` loop is never detected; the doc was honest about that, the name was not, and a public field is far cheaper to rename before release. Deliberately **not** widened to a windowed count — that would regress `an_interleaved_different_call_resets_the_counter`, which pins a considered trade-off: an agent working through a list calls one tool repeatedly and legitimately, and a detector firing on interleaved repeats would be worse than none. The limitation is documented on the field and pinned by a test, so it is a decision rather than a surprise. `steered` held a full clone of every steered signature's arguments — for a file-write tool, the entire file body — unbounded, inside the one type whose job is bounding runaway resource use. Now a capped list of FNV hashes, sharing the one `fnv1a` that `tool_output_key` had inlined. - **Release-gate fixes found by a whole-release review** (pre-0.18.0). Eight commits were each reviewed alone; reviewing them together found defects in the interactions no single-PR review could see. **Loop detection corrupted the transcript.** The steering nudge and the abort message were pushed the moment the verdict came back — which is *before* the tools run — so the next request carried `assistant(tool_use)` → `user(text)` → `user(tool_result)`. Every provider rejects that shape, and the resulting 400 names tool_result blocks rather than loop detection. The abort was worse: it returned with the calls unanswered, and since `Agent::prompt` keeps those messages, a loop-aborted agent was poisoned — every *later* prompt failed too. The nudge is now deferred until the results are appended, and the abort synthesizes an `is_error` result per outstanding call before stopping. It still does not run the tools. This invariant is treated as sacred elsewhere in the crate — `context.rs` calls an unanswered call "an orphan every provider rejects" and `llm_compaction.rs` has a dedicated test for it. Loop detection was the one path that broke it, and no test caught it because `MockProvider` ignores message shape and every loop test asserted only on the event stream. There is now a transcript-invariant assertion, and reverting the fix fails it. **Stash retrieval was re-truncated by the cap it exists to escape.** The marker says `shared_state get "tool-out-…"`; the model calls it; the full text came back through the same append-path cap that stashed it. 2000 lines in, 200 out, plus a fresh stash entry against the cap on every attempt. The headline feature of this release did not work through the path the model actually takes — only from Rust, which is what the tests asserted. `shared_state` now joins `read_file` in `default_tool_output_overrides`. **`is_configured()` ignored `context_tier`,** so a config priced only above its threshold reported "pricing unknown" and billed every request at $0. **GASP recorded a loop abort as `"completed"`.** `LoopDetected` fell into a `_ => {}` wildcard, so the outcome stayed whatever the last stop reason mapped to. The CHANGELOG claimed the opposite. Now `loop_aborted:{tool}`. **`Agent::with_shared_state` shipped undocumented** — its doc block ran into `take_tools`'s with no separator, landing the whole thing on a private function. The release's headline API would have rendered blank on docs.rs. **An `Abort` later in a batch was downgraded to an earlier `Steer`,** and its signature never reached `steered`, giving it a free pass the next turn too. `LoopVerdict` now derives `Ord` — declaration order is severity order — and the loop keeps the worst verdict rather than the first. It is also `#[must_use]`: an ignored verdict silently disabled detection while still advancing the tracker. **The loop-detection text reaching the model carried ~40-space runs**, from literals wrapped across source lines without `\` continuations. `rustfmt` does not reformat literal contents and clippy has no lint for it. **The price audit could pass having compared zero fields.** Its guard derived the expected count from the same list it was checking, so an empty `presets()` reported "0 compared, 0 drifted" and passed. It now pins a floor against the number of priced constructors. **Breaking: `CostConfig::new` and `ContextTier::new` take two rates, not four.** Cache rates move to `with_cache_read`/`with_cache_write`. Four same-typed `f64`s in a row is a transposition hazard, and no vendor publishes them in one order — Anthropic lists input/cache-write/cache-read/output, OpenAI lists input/cached-input/output. A transposed config still returns `true` from `is_configured`, so every downstream guard passes; that is how `claude_sonnet_5` billed 50% high for 18 releases. This repo had already made the mistake: `examples/llm_compaction_live.rs` silently dropped cache-write pricing in a cost-measurement harness. `ContextTier::cache_write_per_million` was also unreachable — `new` hardcoded it to 0.0 with no setter on a `#[non_exhaustive]` struct. **Breaking: `CostConfig::context_tier: Option` is now `context_tiers: Vec`,** kept sorted by threshold. Vendors publish multi-step schedules and models.dev already models this as an array — which this release's own audit reads at `/tiers/0/`. Leaving it as one tier would have cost a second breaking release, and would have broken the serde key as well as the field type, invalidating persisted configs. **Breaking: `ExecutionLimits` is `#[non_exhaustive]`** with `with_*` builders, closing a break this crate has now taken twice. Also note `ExecutionTracker` lost struct-literal construction in this release (it gained three private fields) — an undeclared fourth breaking change, recorded here. - **The `AgentEvent` wire freeze now forces a sample for every variant** ([#137](https://github.com/yologdev/yoagent/issues/137)). `EVENT_VARIANT_COUNT`'s message claimed it failed when a variant was added without a serialization sample. It could not fire for that: the integration test's match carries a `_ => "unknown"` arm, forced by `#[non_exhaustive]`, so adding a variant requires no edit to that file at all — the count and the sample list are both hand-written and a new variant appears in neither. #136 added a 14th variant and the suite stayed green, but only because the author added the sample by hand and said so in the commit message; nothing would have complained otherwise. `wire_tag_freeze` in `src/types.rs` now declares the frozen tag **and** a sample for every variant from one list, via a `wire_freeze!` macro that emits both the wildcard-free match and the sample vector: ```rust,ignore AgentEvent::LoopDetected { .. } => "loopDetected" = AgentEvent::loop_detected("bash", 3, false), ``` Adding a variant is a non-exhaustive-match compile error, and the only way to fix it is to add a line here — which supplies the sample in the same breath. The specifier is `pat_param`, not `pat`, and that is load-bearing: `pat` would accept an or-pattern, letting someone answer the compile error by widening an unrelated arm (`TurnStart | NewVariant => "turnStart"`) and leave the new variant with no coverage at all. Each sample is checked for its frozen tag, a JSON round-trip, and camelCase keys **recursively** — a snake_case key at `agentEnd.messages[0].usage` reaches clients as surely as a top-level one. Samples are deliberately populated rather than defaulted: a round-trip cannot notice a field that `#[serde(skip)]` drops if the value it reconstructs is the default anyway. Scope, stated plainly because this entry is about a guard that overclaimed: this freezes tags, per-variant sample coverage, and round-tripping. It does **not** freeze payload shape — a `#[serde(rename)]` on a field still passes. Field names are pinned only where `tests/serialization_test.rs` asserts them by literal. Verified by mutation, since a guard that cannot fire is the bug being fixed: adding a 15th variant is a compile error (the integration test stays green on the same mutation), an or-pattern bypass is a macro parse error, a tag typo fails, dropping `Usage`'s `totalTokens` rename fails on the nested key, and `#[serde(skip)]` on `AgentEnd.stats` now fails the round-trip. Two related fixes. `ToolResult` reaches the wire as `toolExecutionEnd.result` but carried no `rename_all = "camelCase"` — a no-op for today's single-word fields, and a silent snake_case leak for the first multi-word one. And `ContextCompacted`'s doc block had been concatenated onto `LoopDetected` since #136, leaving `ContextCompacted` undocumented. In `tests/serialization_test.rs`: `test_context_compacted_wire_shape_is_frozen` indexed the sample list positionally (`all_agent_events()[12]`), so inserting a variant earlier broke it with a confusing `contextCompacted` tag mismatch rather than an edit to this test; it now looks the variant up. The count guard is kept under an accurate name and message — it never caught an *added* variant, but it does catch a sample being *deleted*, which silently drops that variant's payload coverage. - **Truncated tool output is retrievable instead of lost** ([#125](https://github.com/yologdev/yoagent/issues/125)). `truncate_tool_output` head-tail-truncates on append and the middle was gone irrecoverably — the full text survived only in the event stream, which the *agent* cannot read. `Agent::with_shared_state(state)` now stashes the full output and the marker names where it went: ``` [... 1847 lines truncated — full output: shared_state get "tool-out-tc_01abc-9f2a..." ...] ``` The `shared_state` tool is registered for the run so the model can act on it. `SharedState` was always a parent↔sub-agent medium in Rust, but only `SubAgentTool` registered the tool — so the parent's *model* could not reach a store its Rust caller could read and write freely. **Opt-in.** Without a store attached, truncation behaves exactly as before and the marker advertises no retrieval it cannot honour. **Known limitation:** the pointer survives Level 1 byte-for-byte, but the lossy levels drop whole turns and take the marker with them, while the stash entry lives on and keeps consuming cap quota. Pinned by `lossy_compaction_drops_the_marker_while_the_stash_survives` so a change to that behaviour is a decision rather than a surprise. Three constraints shaped it. The stash happens **only on the append path** — by the time compaction runs, append-path truncation has already discarded the middle, so there is nothing left to store. The key is threaded into marker **generation** rather than substituted afterwards: rewriting the rendered text would hit every occurrence of the marker's shape, including one that came from the tool's own output (ordinary for an agent reading a log or transcript), and would silently do nothing when the budget is too small for a marker at all. And the key combines the tool call id with a **hash of the output**, because `google.rs`/`google_vertex.rs` synthesize ids as a per-response index that restarts every turn — id alone would let turn 1's frozen marker resolve to turn 5's content. - **`CostConfig` models context tiers; no preset uses one** ([#138](https://github.com/yologdev/yoagent/issues/138)). `cost_usd` applied one rate per token category regardless of request size, while some vendors price long-context requests higher. It now takes an optional `CostConfig::context_tier`, and selects rates by the request's **prompt** tokens (`input + cache_read + cache_write`) so a long reply to a short prompt stays on the base rate. ```rust,ignore let cost = CostConfig::new(5.0, 30.0, 0.5, 0.0) .with_context_tier(ContextTier::new(272_000, 10.0, 45.0, 1.0)); ``` **Every shipped preset stays flat**, `gpt_5_5` included — and getting there took three passes, each reversing the last. models.dev records a 272K tier for gpt-5.5 at $10/$45/$1. Reading OpenAI's pricing page said no such row existed. A closer read said it did, as columns rather than rows. Parsing the page's actual markup resolved it: the `>272K input tokens` column group is real, but **gpt-5.5 has no row in it** — the $10/$1/$45 triple belongs to `gpt-5.6-sol`, a model whose short-context rates are identical to gpt-5.5's. The one gpt-5.5 row that does appear in a long-context table, `gpt-5.5-cyber`, has `-` in all four long-context cells. What settled it was checking models.dev's tier data where a vendor states the answer unambiguously: 34 Claude entries carry tiers, including `claude-4.6-sonnet` and `claude-opus-4.6` — models Anthropic's own page says bill the full 1M window at standard rates. Its tier data is demonstrably wrong where it is checkable, so a models.dev tier alone is not grounds to double what callers are charged. #132's doctrine, applied: drift alarm, never authority. `tests/price_audit.rs` now asserts the disputed rates rather than merely naming the keys, so a revision upstream fails the audit and the decision gets re-made against new evidence instead of silently inheriting this one. One caveat for whoever tiers a preset later: prompt size is derived as `input + cache_read + cache_write`, which holds only where the provider subtracts cached tokens out of `input`. `bedrock.rs` populates neither cache field, so a heavily-cached prompt reads small there and would select the cheap tier. **Breaking: `CostConfig` is now `#[non_exhaustive]`** and gained a fifth field, so out-of-crate struct-literal construction no longer compiles. Use `CostConfig::new(input, output, cache_read, cache_write)`, optionally with `.with_context_tier(..)`; `..Default::default()` no longer rescues a literal. Field *reads* and mutation through a binding are unaffected. This is the last cheap moment to close it — every future rate category (per-image, per-second audio, a second tier) would otherwise be another breaking release. - **Truncation stash: one key per content block, and `SubAgentTool` can now reach it at all** ([#134](https://github.com/yologdev/yoagent/issues/134)). A tool result carrying several text blocks got one marker per block, all naming the **same** key, while the stash held the blocks joined by `\n` with any image between them silently dropped. So a fetch returned every block concatenated — never the block whose marker the model had just followed — and the value called "full output" was missing content the transcript still showed. Keys are now block-qualified and each marker names exactly the block it sits in — the suffix is the block's position in the content vector, so text/image/text yields `…-b0` and `…-b2`, not `-b0`/`-b1`. Making that path reachable exposed a second problem it had been hiding: the sub-agent's system prompt embeds a `SharedState` summary, so a second invocation would have seen the first run's `tool-out-*` keys — a *different* system prompt each time, breaking prefix caching on every sub-agent turn and filling the most cache-sensitive text in the request with pointers to kilobyte-sized blobs nobody asked about. The cost is a cold prefix on the first turn of every subsequent delegation, not on every turn — the prompt is built once per invocation. `SharedState::prompt_summary` excludes them; the `shared_state` tool's `list` still shows them, so they stay discoverable at runtime where changing text costs nothing. Separately, the sink wired into `SubAgentTool` in #133 was **dead code**. Sub-agents set `context_config: None`, and the loop gates truncation and stashing together on that being `Some` — so sub-agents never truncated tool output and therefore never stashed. `SubAgentTool::with_context_config` makes both reachable. `max_turns` remains the guard on a sub-agent's *length*; this is about the size of any single tool result it takes in. - **Recorded why `LlmCompaction` summarizes standalone rather than in-session** ([#127](https://github.com/yologdev/yoagent/issues/127)). Appending the summarization instruction to the live session makes the history a prefix-cache hit — an obvious-looking saving that measures badly, because the briefing is then billed at the *loop* model's output rate. Per compaction: Sonnet 5 gains 3%, Opus 5 loses 2.4x, Fable 5 loses 4.8x. The idea was motivated by reusing the expensive model's cache and the expensive model is where it loses. It wins only when the loop model *is* the summarizer (DeepSeek in-session runs 3.2x cheaper than DeepSeek standalone), which is the configuration cheap-model routing exists to avoid. And a background request racing the loop's own turn can miss the cache entirely, paying full input on the whole history — 2.6x the standalone cost on Sonnet. Best case a few percent, failure case a 2.6x overrun. Documented as a no-go in the `llm_compaction` module docs. - **Loop detection in `ExecutionLimits`** ([#126](https://github.com/yologdev/yoagent/issues/126)). The cheapest catastrophic failure mode — a model calling one tool with the same arguments forever — tripped none of the three existing limits early. They all fire eventually, but only after burning the full turn, token and wall-clock budget, so the run costs its maximum to discover it achieved nothing. `max_consecutive_identical_tool_calls: Option`, default `Some(3)`, `None` to disable. Two escalations, mirroring the house pattern of steering before aborting: 1. **First trip** injects a steering message and continues. A model repeating a call is often retrying something transient, and aborting immediately would regress that legitimate case. 2. **A later trip on the same signature** stops the run. Both emit the new `AgentEvent::LoopDetected { tool_name, repetitions, aborted }`, so a UI can show the intervention and GASP can record why a run ended — an audit wants loop aborts distinguishable from turn-limit stops. Signatures compare `serde_json::Value`, not serialized text: two calls differing only in key order are the same call, and string comparison would miss the loop. Counting covers duplicates *within* one batch as well as across turns, because `ToolExecutionStrategy` defaults to `Parallel` and a model can emit the same call three times in a single message. Any different call resets the streak. The check runs **before** tool execution, so a stuck model does not also pay for the tool run it was never going to learn from. - **Breaking: `ExecutionLimits` gained `max_consecutive_identical_tool_calls`.** The struct is not `#[non_exhaustive]`, so downstream struct-literal construction needs the new field or `..Default::default()`. Behaviour changes only for pathological sessions, but it *is* on by default. - **`tests/price_audit.rs` — a drift alarm for the crate's hardcoded prices** ([#132](https://github.com/yologdev/yoagent/issues/132)). `ModelConfig`'s priced presets are `f64` literals compiled into the crate; when a vendor reprices they keep billing at the old numbers until someone edits the source. `claude_sonnet_5` shipped Sonnet 4.6's rates across 18 releases, v0.9.0 through v0.16.5 — 50% high on every `cost_usd` — and nothing detected it. It remains uncorrected on the `release/0.16.x` maintenance line. ```text cargo test --test price_audit -- --ignored --nocapture ``` Diffs all four cost fields of every priced preset against [models.dev](https://github.com/anomalyco/models.dev), which unlike most price aggregators carries `cache_read`/`cache_write` — the fields this crate needs. 26 fields compared clean at time of writing, with 2 (`cache_write` on `gpt-5.5` and `muse-spark-1.2`) not listed upstream and reported as `—` rather than silently compared against 0. Includes `ModelConfig::meta`, whose rates had never been checked against any source (they match Muse Spark 1.1/1.2). The audit refuses to go quiet: it fails if a preset vanishes from the database (a rename would otherwise drop it out of coverage silently), if fewer fields are accounted for than expected (a schema change would otherwise yield a green run that compared nothing), or if models.dev carries cost structure the flat `CostConfig` cannot express. That last one fires on `gpt_5_5` today — models.dev lists a context tier above 272K at double the input rate — recorded as a known gap rather than silently certified. A failure is an **alarm, not an instruction**: models.dev is community-maintained and not authoritative, so the test names the vendor's own page and the constructor to edit, and never updates a constant itself. A model absent from the database is reported rather than failed. Presets now carry the source URL and verification date, and `CostConfig`'s docs state plainly that they are a snapshot — with the override path, since `cost` is a public field and `CostConfig` is `Deserialize`, so nobody has to wait for a release for a negotiated rate. ### Changed - **`FileBackend` is capped at 10MB** — the same limit as `MemoryBackend`, though this backend evicts oldest-first where `MemoryBackend` rejects the write. Without a bound, an agent stashing truncated output would grow the directory until the disk filled. `FileBackend::with_max_bytes` overrides it. A value larger than the cap is **rejected** rather than written and then evicted: returning `Ok(())` for a value already deleted would let the loop annotate a marker naming a key that never existed. Eviction skips the write that triggered it, logs every failed unlink, and warns if it finishes still over the cap. **The directory is owned exclusively by the backend** — eviction unlinks regular files it finds there. A stale key fails to read, which a model handles as an ordinary tool error. - **Breaking: `AgentLoopConfig` gained `tool_output_sink`.** The struct is not `#[non_exhaustive]`, so downstream struct-literal construction needs the new field. ## 0.17.0 > The fixes below also shipped on the **0.16.x maintenance line** — the gasp > extension-path and MSRV fixes in [0.16.3], the gasp id types in [0.16.4], and > the method-surface closure in [0.16.5] — cut from `v0.16.2` so consumers could > take them without the breaking changes released here. ### Added - **`LlmCompaction` — an opt-in compaction strategy that summarizes instead of dropping.** `DefaultCompaction`'s lossy tiers discard early decisions and constraints outright. `LlmCompaction` sends a standalone summarization request and splices a prose handoff briefing in where the dropped span used to be. `CompactionStrategy::compact` is synchronous and sits on the hot path before every turn, so the request runs in the background: it starts when usage crosses `trigger_ratio · budget` and the result is spliced when the budget is actually crossed. The loop never stalls on it, and it can never wedge — an unfinished, failed, or stale summary falls back to `compact_messages` for that turn. Two invariants are load-bearing and were each a bug first, caught by review before release. The background request is fingerprinted against the history `compact` is about to **return**, not the one it received: the loop writes the return value straight back, so anchoring on the input meant the deterministic fallback invalidated the summary its own call had just ordered — an absorbing state costing one billed request per compaction for a result identical to `DefaultCompaction`'s (measured: 22 requests, 0 splices over 25 turns). And an over-budget splice compacts only its tail, because the summary sits exactly where `level3_drop_middle` starts cutting and the naive whole-history pass deleted the briefing while the event still reported `Summarized`. This buys **retention quality, and costs tokens**: each summarization pays input tokens for the summarized span plus output tokens for the briefing, which `DefaultCompaction` never pays. It does **not** reduce prefix-cache breaks — 6 vs 6 over 120 turns at a 20k budget, 6 vs 5 over 600 turns at 100k, per `tests/prefix_cache_harness.rs`. Route it at a cheap model; both paths report their own cost on the new event below. ```rust,ignore let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Sonnet 5")) .with_compaction_strategy(LlmCompaction::from_config( ModelConfig::anthropic("claude-haiku-4-5", "Haiku 4.5"), )); ``` Known limitation: `compact_target_ratio` / `compact_headroom_turns` do not apply to this strategy — its result is sized by `with_retain_tail_tokens` instead. Consuming the loop's adapted target is follow-up work. - **`AgentEvent::ContextCompacted`**, with the new `CompactionMethod` enum and `SummaryStats` payload. Emitted by `LlmCompaction` on both paths — the spliced summary and the deterministic fallback — carrying messages and tokens before/after plus, when a request was made, a `summary: Option` holding the span it bought, its `Usage`, and its dollar cost. That last part is the point: it makes an LLM compaction strategy priceable against `DefaultCompaction` instead of a guess. One optional payload rather than three sibling `Option`s so the cost, the span, and the fact that a request happened cannot disagree. `DefaultCompaction` does not emit it (no event channel, and no request to price). - **`tests/prefix_cache_harness.rs`** — an `#[ignore]`d measurement harness that drives any `CompactionStrategy` through a simulated session (append a turn, compact, feed the result back, as the loop does) and counts the rounds that rewrite history. Every prefix-cache figure quoted in the docs comes from it and can be regenerated with `cargo test --test prefix_cache_harness -- --ignored --nocapture`. Deliberately not scoped to `LlmCompaction`: prefix-cache effectiveness has two halves, and this covers history stability. Wiring `CacheStrategy` through the non-Anthropic providers is the other half, and should extend this harness with provider-side breakpoint assertions rather than reimplement the session simulation. - **`examples/llm_compaction_live.rs`** — a live evaluation harness for the one thing mocks cannot check: whether the briefings are any good. Runs a real multi-turn session at a small context budget, forces splices, records into a GASP repo, and prints every briefing verbatim next to what it cost. The turns establish constraints early and ask the model to recall them after the splice, so a summary that drops them shows up as self-contradiction. `YO_DRY_RUN=1` exercises the whole pipeline against a stub — no key, no bill — which is how the harness's own wiring was verified before it was handed over. - `StopReason` is documented as `#[non_exhaustive]`, which it has been since 0.17.0 — the doc comment still claimed the opposite. - **`AgentEvent::AgentEnd` carries a `SessionStats` rollup** ([#124](https://github.com/yologdev/yoagent/issues/124)). Every per-turn number already existed — `Usage` on each assistant message, `tokens_cached` on the `llm_stream` span, `cache_read` in the GASP record — but nothing summed them, so "what was this run's cache hit rate" meant replaying the event stream. Cache-affecting changes were judged by hand-built harnesses instead of a number the library reports; #123 and the #119 evaluation both had to do exactly that. ```rust,ignore AgentEvent::AgentEnd { stats, .. } => { println!("{:.1}% cached over {} turns", stats.cache_hit_rate() * 100.0, stats.turns); } ``` Carries summed `usage`, `turns`, `cost_usd` when rates are configured, and `compactions`. Hit rate delegates to `Usage::cache_hit_rate` so there is one definition rather than two that drift — and it counts `cache_write` against you, because those are prompt tokens the provider processed and billed. `usage.total_tokens` is deliberately **not** summed and stays 0. It is a per-response provider report and the providers disagree: `anthropic.rs` never sets it, `bedrock.rs` computes `input + output` and excludes cache, and the rest pass through a payload value that includes cached tokens. Summing it would launder that into a session-level figure that reads as authoritative and is 0 for every Anthropic run. Derive a total from the four components. Scope: a run's own turns. `SubAgentTool` runs its own loop on a private channel, so a delegating agent's real spend is higher than this reports. `compactions` counts turns where the strategy changed the message count *or* the token total, so in-place tool-output truncation is included rather than missed by a length check. It is deliberately not split into spliced-summary vs deterministic fallback and carries no summarization spend: that detail lives on `AgentEvent::ContextCompacted`, and `CompactionStrategy::compact` is synchronous with no event channel, so the loop cannot see it. Wire `LlmCompaction::with_event_sender` to the same channel to aggregate both. The GASP run-close record now carries the same rollup, so an audit can compare runs without replaying every `model.finished` entry. - **`CacheStrategy` is no longer Anthropic-only** ([#123](https://github.com/yologdev/yoagent/issues/123)). The 0.16.x line engineered byte-stable compaction so cached prefixes survive — and that engineering paid off on one protocol out of seven, because `cache_config` was read only by `anthropic.rs`. This takes it to two of seven: Azure, the OpenAI Responses path and Bedrock all accept cache directives and remain unwired. Native OpenAI now receives `prompt_cache_key`. OpenAI caches prefixes ≥1024 tokens automatically, so there are no breakpoints to place; the key routes requests to a machine, and the cache itself stays content-addressed. It is derived from the **system prompt**, which is the cacheable prefix and which `StreamConfig` carries in its own field where compaction cannot reach it. Sessions sharing a system prompt therefore share a key. That is the correct grouping — they share the cached prefix — but it concentrates traffic, so high-volume deployments should set the new `CacheConfig::session_key` explicitly to spread load. A first implementation also mixed in the first user message, for session discrimination, and review caught that it does not survive the crate's own compaction: `compact_messages` can drop the head and insert a *constant* marker at index 0 (constant on purpose, so the cached prefix stays byte-stable). The derived key then drifted mid-session **and** collapsed onto one value for every session sharing a system prompt — failing precisely on long sessions, which are the ones caching exists for. Session identity is not recoverable from a per-request snapshot. `derived_key_survives_compaction_that_rewrites_the_head` pins it; the original tests only appended messages, which is the one condition under which the broken derivation held. `CacheConfig` with every `Manual` flag off now means "no hints" on every protocol, matching what Anthropic already did by placing no breakpoints. Setting `session_key` on a provider that cannot carry it now warns rather than discarding it silently, per the convention stated on `StreamConfig::output_schema`. Gated on the new `OpenAiCompat::supports_prompt_cache_key`, on only for native OpenAI. The field is OpenAI's, and a strict compat server that validates unknown keys would reject the request rather than ignore it. **Gemini stays implicit-only, on purpose.** Its explicit caching is a stateful `CachedContent` resource with its own handle, TTL and billing line — not something `CacheStrategy` can express without misrepresenting it as a per-request flag. Recorded in the `google` module docs so it isn't re-litigated. `CacheStrategy`'s rustdoc previously read *"Anthropic-specific; other providers handle caching automatically regardless of this setting"* and is rewritten around the two shapes — explicit-breakpoint vs key-routed vs automatic — with a per-protocol table. Anthropic behaviour is unchanged. ### Changed - **Breaking: `ToolContext` is `#[non_exhaustive]`** and gained `ToolContext::new` plus `with_cancel` / `with_on_update` / `with_on_progress`. Its doc has always claimed that "adding fields to `ToolContext` is non-breaking". That was false — the struct carried no attribute, so every field addition broke downstream struct literals. Making it true cost 20 literal rewrites across this repo's own tests and examples, which is a fair measure of what a future field would have cost everyone else. Tools *receive* this type rather than construct it, so implementors are unaffected; only code that builds one directly needs the constructor. - **Breaking: `AgentEvent::AgentEnd` gained a `stats` field** and the variant is now `#[non_exhaustive]`. Match with `..`, and construct via `AgentEvent::agent_end(messages, stats)` rather than a struct literal. The field and every `SessionStats` field carry `#[serde(default)]`, so archived event streams written before this release still deserialize — `AgentEvent` is a frozen wire format, and a missing-field error would have broken every replay consumer. - **Breaking: `MockResponse` is `#[non_exhaustive]`** and gained `TextWithUsage(String, Usage)`, so a test can set a turn's usage. `Text` reports `Usage::default()`, which cannot distinguish a rollup that sums from one that copies the last turn. - **Breaking: `CacheConfig` is `#[non_exhaustive]`** and gained `session_key`. Construct with `CacheConfig::new()` / `::disabled()` / `::default()` and the `with_session_key` / `with_strategy` builders instead of a struct literal. `with_strategy` exists because `CacheStrategy::Manual` would otherwise be unreachable from outside the crate. - **Breaking: `OpenAiCompat` is `#[non_exhaustive]`** and gained `supports_prompt_cache_key`. It is the crate's most literal instance of a growing quirk list — ten flags, one per provider difference — and the documented extension point for custom compat servers, so it was the struct most likely to be literal-constructed downstream and the one where every future flag would break users again. Construct from a preset (`OpenAiCompat::openai()`, `::deepseek()`, …) or `Default::default()` and adjust fields. The new flag carries `#[serde(default)]`, so a `ModelConfig` persisted by an older version deserializes with cache routing **off** until re-created from a preset. - **Breaking: `CacheStrategy` is `#[non_exhaustive]`.** It is a data/policy enum whose growth this release was already debating, which is the crate's stated test for the attribute (`StopReason` took the same trade; control-flow enums like `ToolDecision` deliberately do not). Downstream `match` arms need a `_ =>`. ### Fixed - **The `gasp` method surface is now closed mechanically, not by judgement** ([#117](https://github.com/yologdev/yoagent/issues/117)). Three rounds of the same bug — [#111] (the receiver), [#115] (the recorded structs' id types), [#117] (those structs' *field* types and every `YoAgentState` method's argument types) — each found one more tier of `yoagent-state` type that the documented extension path needs but could not name. (3) subsumes the others: a struct can be constructible without being usable, because a smart constructor defaults a field whose type the caller can never name. Now also re-exported: `Event`, `EventId`, `Frame`, `FrameId`, `ModelCall`, `PatchStatus`, `ProjectSnapshot`, and `ToolCall as GaspToolCall`. The rule is enforced by `tests/gasp_test.rs` rather than by inspection, which is what makes a fourth round unlikely. Forward-ported from the 0.16.x line, where it shipped as [0.16.5]. `main` did not have it: the maintenance branch would have been ahead of the development branch on this fix, so 0.17.0 would have regressed it. - **`ModelConfig::claude_sonnet_5` carried Sonnet 4.6's prices**, overstating every `cost_usd` for that model by 50%. It shipped as $3/$15 per MTok ($3.75 write, $0.30 read) against the published $2/$10 ($2.50 write, $0.20 read) — the introductory rate that has since become standard. Callers using `CostConfig::cost_usd`, `Agent::session_cost_usd`, or the `cost_usd` field on the `llm_stream` tracing span will see Sonnet 5 figures drop by a third. Fable 5, Opus 5, Opus 4.8 and Haiku 4.5 were checked against the same table and were correct. - **The `gasp` extension path could not construct its arguments** ([#115](https://github.com/yologdev/yoagent/issues/115)). The recorded structs were re-exported without the id types their fields require, so `StatePatch`, `EvalResult` and `Decision` were nameable and unbuildable. `Task` worked only because `TaskId` was already in the list — which is why the gap survived inspection, and why the 0.16.3 example, which constructs a `Task`, compiled while its siblings did not. Now re-exported: `PatchId`, `EvalId`, `DecisionId`, `HypothesisId`, `ObservationId`, `ArtifactRef`, `ExpectedEffect`, `Precondition`, `ProjectRef`, `StateOp` — found by auditing every re-exported struct's fields rather than only the three in the report. A test now constructs every re-exported struct from `yoagent::gasp` alone, so a struct that is nameable but unbuildable fails to compile. ### Changed - CI runs on `release/**` as well as `main`. The 0.16.x maintenance line had no CI, so 0.16.3 shipped verified only locally. [0.16.3]: https://github.com/yologdev/yoagent/releases/tag/v0.16.3 [0.16.4]: https://github.com/yologdev/yoagent/releases/tag/v0.16.4 [0.16.5]: https://github.com/yologdev/yoagent/releases/tag/v0.16.5 [#111]: https://github.com/yologdev/yoagent/issues/111 [#115]: https://github.com/yologdev/yoagent/issues/115 [#117]: https://github.com/yologdev/yoagent/issues/117 ### Fixed - **The documented `gasp` extension path was unreachable** ([#111](https://github.com/yologdev/yoagent/issues/111)). 0.16.0 re-exported the types the `YoAgentState::record_*` methods *take* but not the receiver they are called *on*, and `GaspRecorder` kept its state, store and actor private — so an application could construct a `Task` and have nothing to record it with. The workaround was a direct `yoagent-state` dependency, precisely the version-skew hazard the doc comment warned against. Both halves are fixed: `ActorRef`, `GitEventStore`, `Node`, `NodeId` and `YoAgentState` are now re-exported, and `GaspRecorder` gained [`state()`](https://docs.rs/yoagent/latest/yoagent/gasp/struct.GaspRecorder.html#method.state), `store()` and `actor()`. Prefer the accessors over opening your own store: a GASP repo is single-writer behind a 600-second lease, so a second `GitEventStore` on the same root collides with the recorder's rather than cooperating. ### Changed - **Breaking: `AgentEvent`, `StreamEvent`, `StreamDelta`, `StopReason` and `ApiProtocol` are now `#[non_exhaustive]`.** These five grow with provider and protocol features, so every addition was previously a breaking change — the reason yoagent#104 routed around adding a compaction event rather than adding one. Downstream `match` arms need a `_ =>` wildcard; new variants are additive from here. Other enums were deliberately left exhaustive: `Message`, `AgentMessage`, `FilterResult`, `ToolDecision` and the config enums are closed shapes, and marking them would break a great deal of downstream code for no expected growth. The `AgentEvent` / `StreamDelta` **wire-tag freeze moved into the crate** (`types::wire_tag_freeze`). It relies on exhaustive matching to fail compilation until a new variant's serde tag is pinned — a guarantee that no longer holds from an integration test once the enum is `#[non_exhaustive]`. Verified by adding a probe variant: it still fails to compile. ### Added - **`SharedState::scoped` — opt-in isolation between sub-agents.** The store is a shared flat namespace by design, so this is a view rather than a default: keys are transparently prefixed, and `keys()` / `summary()` report only that scope. A scoped sub-agent cannot read, overwrite or enumerate anything outside it (prefixing is applied on the way in, so a crafted key cannot escape), while the parent's unscoped handle still sees everything — which is what lets it collect results. Scoping nests, so a view can narrow but never widen. `summary()` matters most here: it is injected into the sub-agent's system prompt, so an unscoped one disclosed every sibling's key names. - `SubAgentTool::with_scoped_shared_state` — the isolating counterpart to `with_shared_state`. ## 0.16.2 ### Fixed - **CI's MSRV job broke on unrelated PRs.** `Cargo.lock` is not committed, so every run re-resolves to the newest compatible releases — and when `icu_*` 2.3.0 (via `reqwest` → `url` → `idna`) raised its own rust-version to 1.88, the MSRV job started failing on `main` and every open PR without a commit causing it. Setting `resolver = "3"` turns on cargo's MSRV-aware resolution, so dependency versions are chosen against our `rust-version` instead of ignoring it. No CI or dependency changes; downstream consumers resolve with their own resolver and are unaffected. - **`allowed_paths` was declared but never enforced.** `ReadFileTool` accepted an allowlist of directory roots, defaulted it, and then ignored it — callers who set it believed reads were sandboxed and they were not. It is now enforced, and the same allowlist was added to `WriteFileTool`, `EditFileTool`, `ListFilesTool` and `SearchTool`, which previously had no path restriction at all. Checks run against the **resolved** path via the new [`tools::PathSandbox`], so `..` and symlinks cannot escape, and a not-yet-created file resolves through its real parent so writes are covered. Rejections do not echo the allowed roots (tool results reach the model's transcript). Default stays unrestricted — no behaviour change unless the allowlist is set. - **Credentials could reach `Debug` output.** `StreamConfig` derived `Debug` with a plaintext `api_key`, and `ModelConfig` derived it with a `headers` map that conventionally carries `Authorization` / `x-api-key`. Any `{:?}` in a log line or panic message printed them. Both now have manual `Debug` impls that redact — `StreamConfig` still distinguishes a set key from an empty one, and `ModelConfig` keeps header *names*. `ModelConfig`'s `Serialize` is intentionally unchanged so saved configs still round-trip. ### Added - `BashTool::with_env_allowlist` — pass only named environment variables (plus `PATH`, `HOME`, `PWD`) to commands. Commands otherwise inherit the agent's full environment, so a model-authored command can read any `*_API_KEY` the process holds. Default behaviour unchanged. ### Changed - Documented plainly that **`BashTool` is not a sandbox**: `deny_patterns` is a substring check for typos, bypassed by whitespace, quoting or encoding. Isolation belongs in a container/VM or `ToolMiddleware`. ## 0.16.1 ### Added - `SkillSet::load_resilient` / `SkillSet::load_dir_resilient` — keep valid skills available while reporting malformed or unreadable `SKILL.md` files individually, instead of one bad skill discarding the whole set (#74). `load` / `load_dir` stay strict; their first reported error is now deterministic (sorted path order) rather than filesystem order. ## 0.16.0 GASP recorder v2 ([#104](https://github.com/yologdev/yoagent/issues/104)): the recorded log becomes sufficient for offline cost analysis, compaction inference, and call matching — the substrate for measuring whether compaction actually hurts an agent, rather than only what it costs. ### Added - **`tool.called` carries a stable argument fingerprint** (`metadata.args_fingerprint`). `input_summary` is a 200-char human summary, so calls could not be matched across a log. The fingerprint is normalized per tool — path for the file tools, command for `bash`, full args otherwise — because `read_file` pages since 0.15 and a re-read of a lost file arrives with different offset bytes; hashing raw args would undercount re-fetches for exactly the most diagnostic tool. FNV-1a, stable across platforms. - **`model.finished` carries token usage** (`metadata.usage`: input, output, cache_read, cache_write). This makes real cost computable from the log, and makes compactions *inferable* — a sharp input-token drop between consecutive model calls in one run is the compaction signature — which is why no compaction event kind (and no `AgentEvent` break) is needed. - **`GaspRecorder::with_store` is public** — the extension path for applications recording the goal/task/verdict tier into the same ledger: open the `GitEventStore` yourself, record goals/tasks on your own `YoAgentState`, hand the recorder the same handle. One store, one writer. The writer model (one store per agent, `worker_id` per writer, commit at run close, push-per-run advice for ephemeral runners) is now documented on the method. - **`gasp` module re-exports the extension-path types** (`Task`, `TaskStatus`, `StatePatch`, `EvalResult`, `Goal as GaspGoal`, …) so applications need no direct `yoagent-state` dependency kept in version lockstep. - Compaction levels 1 and 2 now emit `tracing::debug!` (tokens/messages before → after); previously only level 3 and budget calibration reported. ### Changed - **Breaking:** `yoagent-state` 0.4 → 0.5. Its types are re-exported from `yoagent::gasp`, so this changes public type identity for code that also depends on `yoagent-state` directly. Sidecar processes with their own `yoagent-state` (separate binaries) are unaffected; 0.5 events are wire-compatible both ways with 0.4 logs. ## 0.15.0 ### Fixed - **Compaction rewrote history in place, discarding the provider's prefix cache** ([#99](https://github.com/yologdev/yoagent/issues/99)). A cache hit requires a byte-identical prefix, so every rewrite of already-sent history costs full price for every token from the rewrite point on — automatically on DeepSeek, and via `cache_control` on Anthropic. Four separate sources of churn are gone: - `truncate_text_head_tail` was not idempotent. The `[... N lines truncated ...]` marker pushed the result past `tool_output_max_lines`, so the next compaction pass re-truncated it and restated the count (`950 lines truncated` became `3 lines truncated`) — a second full-prefix invalidation, and a marker that lied about how much was dropped. The marker is now charged against the line budget, so the result fits exactly and re-truncating is a no-op. - Compaction reduced to whatever just barely fit, so the next turn crossed the budget again and rewrote history *every turn*. The new `ContextConfig::compact_target_ratio` (default `0.7`) makes the lossy levels reduce to a fraction of the budget while still triggering at the full budget. Set it to `1.0` for the old behaviour. - Level 3's marker embedded a message count and sat at a fixed position near the front, so the cached prefix changed on every pass. The marker text is now constant and the count goes to the debug log. - Level 2's generated summaries and the Level 3 marker were stamped with `now_ms()`, making compaction non-deterministic. They now inherit the timestamp of the content they replace. - **Compaction could orphan tool calls.** Level 2's boundary (`len - keep_recent`) could land mid-turn, summarizing away an assistant message while its tool results stayed in the kept section; Level 3 and `keep_within_budget` could cut the mirror image. Every provider rejects both. Boundaries now snap to turn starts. ### Added - `ContextConfig::compact_headroom_turns` (**default `Some(30)`**) — compact hard enough to buy that many more turns at the session's observed growth rate, instead of to a fixed fraction of the budget: ```text target = budget − turns × growth_per_turn ``` A fixed ratio cannot know how fast a session is growing, so the room it leaves collapses as history accumulates: at the old default the gap between compactions fell from 36 turns to 22 over a 2400-turn session, and the rewrites piled up. The adaptive target holds it flat (36 → 35). The effective ratio is `min(derived, compact_target_ratio)` floored at `MIN_HEADROOM_RATIO`, so the policy can only compact harder than the ratio alone, never softer; `None` restores pure ratio behaviour. Growth is measured by the agent loop, so a direct `compact_messages` call is unaffected. - `ContextConfig::truncate_tool_output_on_append` (**default `true`**) applies the line cap when a tool result is appended rather than retroactively during compaction. Retroactive truncation was the single largest remaining source of cache loss: output is sent in full, cached, then rewritten in one sweep once the session goes over budget. Capping on the way in also slows context growth, so compaction runs less often. The untruncated output is still carried by the `AgentEvent` stream. - `ContextConfig::tool_output_max_lines_overrides` — per-tool line budgets. One global number cannot serve every tool: head+tail is a good cut for command output (first error at the top, summary at the bottom, repetition in the middle) and the *wrong* cut for a file read, where the middle is the part that was asked for. The default exempts `read_file`, which bounds itself by paging instead. `usize::MAX` disables truncation for a tool. - `ReadFileTool::max_lines` / `tools::DEFAULT_READ_MAX_LINES` (500) — an unqualified read used to return the whole file. Measured against a real Rust codebase, the median source file is 1364 lines and 96% exceed 200, so one read could spend ~15K tokens; reads are ~19% of tool calls. Paging is the right bound for a file — unlike head+tail it is lossless and directed, and the header now states the true total and tells the agent to use `offset`/`limit`. 500 was chosen by measurement: hit rate is flat between a 300- and 500-line page and falls off sharply above it. - `context::truncate_tool_output()` — the single-message helper behind Level 1, now public for custom loops and compaction strategies. - `tests/context_cache_test.rs` replays a session through `compact_messages` turn by turn and measures the shared prefix between consecutive requests — the direct analogue of DeepSeek's `cache_hit_tokens / input_tokens`. The tool mix (bash 41%, edit/write 36%, read 19%, search 4%) and the file-size distribution are taken from 808 archived runs of a production agent built on yoagent. Over 300 turns on a 128K window: | session | 0.14.2 hit | 0.15.0 hit | 0.14.2 rewrites | 0.15.0 rewrites | |---|---|---|---|---| | 300 turns | 93.83% | **95.69%** | 34 | **8** | | 1200 turns | 94.24% | **95.39%** | 169 | **35** | | 2400 turns | 94.77% | **95.27%** | 415 | **70** | Priced as input-token spend over the whole session — the metric that matters, since hit rate alone rewards carrying a larger context: | session | DeepSeek 0.14.2 → 0.15.0 | Anthropic 0.14.2 → 0.15.0 | |---|---|---| | 300 turns | $1.6511 → $1.4985 (**−9.2%**) | $9.3567 → $7.9347 (**−15.2%**) | | 1200 turns | $6.9307 → $5.7563 (**−16.9%**) | $38.7284 → $30.8415 (**−20.4%**) | | 2400 turns | $14.3065 → $11.2566 (**−21.3%**) | $78.4390 → $60.5804 (**−22.8%**) | ### Changed - Level 3 now drops the smallest span of middle messages that reaches the target instead of always collapsing to `keep_first` + `keep_recent`, so compaction destroys only as much history as the budget requires. - `ContextConfig::tool_output_max_lines` default raised from 50 to 200. It now applies on append rather than only under budget pressure, and 50 lines (≈23 head + 24 tail) cuts the error list out of most build output. - **Breaking:** `ContextConfig` gained four public fields (`compact_target_ratio`, `compact_headroom_turns`, `truncate_tool_output_on_append`, `tool_output_max_lines_overrides`) and `ReadFileTool` gained `max_lines`. Code that constructs either with an exhaustive struct literal needs `..Default::default()`; code using `default()`, `new()`, `from_context_window()`, or functional update syntax is unaffected. - **Breaking (behaviour):** tool output is now capped as it enters the context, and an unqualified `read_file` returns 500 lines instead of the whole file. To restore the old behaviour set `truncate_tool_output_on_append: false` and `ReadFileTool { max_lines: usize::MAX, ..Default::default() }`. ## 0.14.2 ### Fixed - **docs.rs was rendering an incomplete crate.** Without `[package.metadata.docs.rs]`, docs.rs built with default features, so the `openapi` and `gasp` modules — both advertised in the README — were missing from the published API reference. The build now enables all features and passes `--cfg docsrs`, and both feature-gated modules carry `doc(cfg(..))` availability badges. ### Changed - **README rewritten for first-time readers.** It now leads with the one command that runs a real coding agent against a local model with no API key, a Quick Start that actually calls a tool (every snippet compile-checked), a diagram of the loop, and a section stating plainly what yoagent does *not* do and which crates to use instead. Capabilities moved from a ~40-bullet wall into six collapsible groups. - Surfaced what the README previously omitted: `SharedState`, `InputFilter`, `MockProvider`, cost tracking, `set_model`, the OpenCode gateways, all ten examples (nine were unreferenced), and the testing story — 456 of 463 tests run with no network and no API keys. - Corrected stale claims: the CLI example is 370 lines, not ~250; the OpenAI-compatible implementation covers 12 compat profiles, not "15+"; and the module map now covers all of `src/`, including `mcp/`, `openapi/`, `session`, `skills`, `shared_state`, `retry`, and `gasp` — roughly 4,000 lines the old architecture tree left out. - `docs/introduction.md`, the landing page the `homepage` field points at, was a 27-line subset that never mentioned sub-agents, tool middleware, structured outputs, skills, session trees, GASP, MCP, or OpenAPI. Rewritten to match the README and link the pages that cover each area. - Crate metadata: filled the three unused crates.io category slots and swapped two low-traffic keywords for `anthropic` and `openai`. ### Added - `CONTRIBUTING.md`, `SECURITY.md`, issue templates (the bug report asks for the protocol and model id up front, since most reports are provider-specific) and a pull request template — the repository previously had none. - Loop and sub-agent/shared-state diagrams in `docs/images/`, with light and dark variants. - A **Built with yoagent** section listing the projects that depend on the crate, headed by [yoyo-evolve](https://github.com/yologdev/yoyo-evolve), plus an invitation to add your own. ## 0.14.1 ### Fixed - **Tool calls with unusable arguments are surfaced instead of executed** (#89, Anthropic provider). `content_block_stop` is what turns a tool call's streamed `input_json_delta` accumulator into real arguments. When it did not — the accumulated text was not valid JSON — the provider substituted an empty object and carried on, so the tool executed with default arguments while neither the caller nor the model learned the model's actual input had been dropped. `list_files` asked for `/etc` would list the process's working directory instead. A single check after the stream now fails the turn if *any* tool call still carries the accumulator, whichever route left it there: unparseable JSON, a `content_block_stop` whose body is not JSON, or one with no `index` (which no longer silently closes block 0 — a block the event was never about). The error names each tool and quotes its input, truncated, since the accumulator can be a whole `max_tokens` worth of JSON and it reaches logs, session files, and — through `SubAgentTool` — the parent model's context. `agent_loop` returns on `StopReason::Error` before extracting tool calls, so nothing runs. *Every* tool call in the message is replaced with text, not just the unusable one: the turn executes none of them, so any left behind would return to the API as a `tool_use` with no `tool_result` and be rejected on the next request — breaking the conversation rather than just the turn. Replacing in place keeps `content.len()` aligned with the provider's block indices. A refusal reported in the same response keeps its `Refusal` stop reason and its explanation; the tool-argument note is appended rather than substituted, so an in-stream context overflow still matches `Message::is_context_overflow()` and its compaction-retry hook. - **`end_turn` and `stop_sequence` are recognized stop reasons** (Anthropic). They previously fell through to the catch-all. Harmless until the catch-all gained a warning — at which point every healthy turn logged one. - **`pause_turn` is reported as incomplete rather than finished** (Anthropic). It means the model stopped mid-turn and expects the conversation to be re-sent to continue; mapping it to a normal stop handed back a truncated answer as though it were complete. This transport cannot resume, so it now says so. - **A trailing `message_delta` no longer zeroes the output token count** (Anthropic). `usage` is optional on the wire, and a defaulted struct made an absent block indistinguishable from a reported zero, wiping cost accounting and `ContextTracker` calibration for the turn. Such a delta already no longer overwrites a stop reason an earlier one established. - **Structured-output responses keep their error message** (`agent_loop`). The rebuild that unwraps a forced tool call preserved the stop reason but dropped the explanation, so a failed structured call surfaced as `"provider error (no detail)"`. ## 0.14.0 ### Added - **MCP `HttpTransport` speaks the request/response subset of Streamable HTTP** (#82). Servers that answer a POST with a `text/event-stream` body — the transport introduced in MCP revision 2025-03-26 — previously failed with `Response parse error`, because the body was parsed as a single JSON object. `HttpTransport` now parses the JSON-RPC payload out of SSE events (joining each event's `data:` lines per the SSE spec), advertises `Accept: application/json, text/event-stream`, captures the `Mcp-Session-Id` the server assigns and replays it on later requests, releases it with a best-effort `DELETE` on `McpClient::close()`, and treats `202`/`204` with an empty body as an acknowledgement rather than a parse failure. Response selection is structural, not "first frame that parses": a frame is this request's response only if it carries no `method`, carries a `result` or an `error`, and its id matches. That matters because every field of `JsonRpcResponse` except `jsonrpc` is optional, so the `notifications/progress` and `notifications/message` frames a server emits *ahead of* the result deserialize into an empty response — accepting the first frame that parsed would have returned the notification and discarded the answer. Diagnostics improve alongside: a non-2xx now carries the server's explanation instead of a bare status, an empty 2xx that is not an acknowledgement is reported rather than dressed up as success, and a `404` on a session-bearing request clears the dead session and says it expired instead of failing every later call with what looks like a bad URL. The body is parsed incrementally: a call returns at the blank-line-terminated frame carrying its response rather than at end-of-stream, so a server that holds the POST stream open after answering does not block it, and a long `tools/call` streaming progress frames returns the moment its result lands. A stalled server is bounded by an idle read timeout (120s, reset on every read, so a slow-but-progressing call is never cut off) rather than hanging. No API-surface changes — `McpTransport` is unchanged, `HttpTransport`'s fields were already private, and `McpClient::connect_http` is untouched. Behavior on the wire does change: every POST now carries an `Accept` header (plus `Mcp-Session-Id` once assigned), `close()` went from a pure no-op to a network round-trip, and returning mid-body means an SSE response forgoes connection reuse unless the server had already closed the body — a fresh connection, and TLS handshake, on the next call to such a server. Not covered: the `GET` server→client stream and `Last-Event-ID` resumability. `McpTransport` is `send`/`close` only, so a server-initiated message has nowhere to be delivered. Reported by @markokocic. ## 0.13.3 ### Fixed - **Anthropic messages carry the configured provider** (#81). `AnthropicProvider` hardcoded `provider: "anthropic"`, the sole outlier among providers — every other one propagates `ModelConfig.provider`. This mis-attributed gateways speaking the Anthropic Messages protocol, including yoagent's own `ModelConfig::opencode_zen()` preset, which routes Claude model ids over this provider under the name `"opencode-zen"`. Falls back to `"anthropic"` when no `ModelConfig` is supplied. - **Truncated SSE streams retry instead of failing hard** (#83). A stream whose body ended without an SSE terminator mapped to the non-retryable `ProviderError::Other`, so the agent loop gave up immediately even though a gateway returning a well-framed body with a truncated payload is usually transient. It now maps to `Network`, which the retry policy already covers. (A mid-body connection reset is a decode error and surfaces as `Transport`, not `StreamEnded` — the two are distinct.) Three providers needed work so that reclassification could not retry an already-billed response: `anthropic` gains the clean-EOF guard `openai_compat` got in #76, armed only by a `message_delta` carrying a terminal `stop_reason` and refusing to return a tool call whose arguments never finished streaming; `openai_responses` and `azure_openai` now break on `response.incomplete` and `response.failed`, which previously fell through and turned one billed generation into four. - **`message_delta` without a `usage` field no longer loses its stop reason** (Anthropic). The field was required, so relaying gateways that omit it failed the whole parse and silently downgraded `tool_use` / `max_tokens` / `refusal` to a plain stop. - **Dropping a streaming `Agent` no longer orphans its task** (#84). `JoinHandle` does not cancel on drop, so the spawned loop kept running — burning tokens, holding the tools, and keeping the event channel open so the caller's receiver never closed. A `Drop` impl now cancels the token and aborts the handle. **Behavior change:** a dropped `Agent` now *kills* its run. Keep the `Agent` alive until you have drained the receiver — dropping it early closes the channel without an `AgentEnd` event, which a consumer looping on `recv()` cannot distinguish from a clean finish. It logs a warning when this happens. Tools are dropped rather than recovered, and a tool blocked in synchronous code still runs until it yields; both are documented on the impl. Thanks to @markokocic for reporting #81, #83, and #84. ## 0.13.2 ### Added - **`ModelConfig::claude_opus_5()` preset** (#85). Consumers mapping model strings to presets had no constructor for `claude-opus-5` and fell through to the generic `ModelConfig::anthropic()`, losing cost tracking and context metadata. The preset sets a 1M context window, a 64K default `max_tokens` (of the model's 128K ceiling), and $5 / $25 per M input/output with $0.50 / $6.25 cache read/write — the same base rates as Opus 4.8. No new compat flag was needed: Opus 5 accepts the same adaptive-thinking encoding as Opus 4.7/4.8, which `AnthropicCompat::default()` already emits whenever a thinking level is set. Note that Opus 5 thinks server-side when a request omits `thinking`, so `ThinkingLevel::Off` does not disable thinking on this model — see the preset's doc comment. ## 0.13.1 ### Fixed - **openai_compat: DONE-less SSE close after `finish_reason` is now a clean EOF** (#76). Providers that close the connection without the OpenAI-standard `data: [DONE]` terminator (MiniMax confirmed in the field) no longer return `ProviderError::Other("Stream ended")` after the complete response already streamed. A `StreamEnded` with no `finish_reason` remains an error — genuine truncation still surfaces (network-level drops retry; deliberate server closes fail fast). ## 0.13.0 ### Added - **Serializable event stream** — `AgentEvent` and `StreamDelta` now derive `Serialize`, `Deserialize`, and `PartialEq`, so external frontends (websocket fanout servers, TypeScript clients, JSONL pipes) can consume the agent's event stream as JSON. The wire format is internally tagged camelCase — `{"type":"toolExecutionEnd","toolCallId":...,"isError":false}` — and is a **frozen public contract** guarded by snapshot tests: variant tags, field names, and the tagging scheme will not change in minor releases. Additive only: no variant, field, or signature changes. ### Changed - **Message payload serialization normalized to camelCase** — the five remaining snake_case fields in serialized messages now match the rest of the wire format: `usage.cacheRead`/`cacheWrite`/`totalTokens`, `errorMessage`, and `providerMetadata`. Session files and `save_messages` blobs written by older versions still load (`serde` aliases accept the old names). Files **written** by 0.13 load in older versions *without error*, but the renamed fields are silently dropped there: cache/total token counts read as 0, and `errorMessage`/`providerMetadata` — including Gemini thought signatures — are lost. Don't round-trip session files through yoagent < 0.13. The full nested payload shape (message, content blocks, usage) is frozen by an exact-JSON snapshot test. - `serde` minimum version is now `1.0.177` (the release that added `rename_all_fields`, July 2023); no practical impact. ## 0.12.0 ### Added - **Meta Model API (Muse Spark)** — `ModelConfig::meta("muse-spark-1.1", ...)` preset for Meta's OpenAI-compatible endpoint (US-only public preview at launch): 1M context, 128K output, launch pricing pre-configured ($1.25/$4.25 per M, $0.15/M cached input). `reasoning_effort` is wired (`ThinkingLevel` tunes it; note Meta's server default is `medium`). Key resolves from `META_API_KEY`, then Meta's documented `MODEL_API_KEY`. Also available in the CLI example via `--provider meta`. - **GASP bridge** (feature `gasp`) — `gasp::GaspRecorder` records agent runs into a [GASP](https://github.com/yologdev/gasp) agent repo via `yoagent-state`: append-only `state/events.jsonl` (goal/run/model/tool events), one git commit per run (scaffolding committed at init so `git clone` restores a complete agent), stale/interrupted runs closed safely, events teed to your UI **before** recording (a recording failure never blinds the UI; the error surfaces via the returned handle). Redaction hook via `with_summarizer` — summaries of tool inputs/outputs are persisted to a shareable repo. yoagent is now a **tested** GASP-conformant runtime: CI emits a repo and runs the protocol's 7-check suite against a **fresh clone** (the actual restore operation). New `gasp_emit` example and docs page. ## 0.11.0 ### Fixed (pre-release review of the items below) - `prompt_structured` now surfaces provider failures as `StructuredPromptError::Provider` (previously laundered into `Parse { raw: "" }`), scans only messages produced by the current call (never stale history), and threads the schema per-call so a dropped/timed- out future can't leave the agent stuck in schema-forcing mode. - Bedrock replays `Content::Thinking` blocks (with signatures) on subsequent requests — previously captured and then dropped, breaking multi-turn thinking + tool use with a ValidationException. - Anthropic structured outputs disable extended thinking for that request (forced tool choice + thinking is an API-level conflict) with a warning. - Vertex now round-trips Gemini thought signatures on function calls (parity with the Gemini API provider). - `Session::append_new` verifies the history still extends the session path and returns `HistoryDiverged` instead of silently corrupting the tree (the usual cause: context compaction); `from_jsonl` validates ids and parents (duplicates/dangling/cycles rejected); `seek_checkpoint` is latest-wins. - A panicking `ToolMiddleware` is contained as a denial instead of killing the loop task (which stripped the agent of its tools). - Middleware denials are logged (`tracing::warn!`) so operators see them. - `ToolMiddleware::before_tool` takes a `ToolCallRequest` context struct (extensible without breaking implementors); `StreamConfig` and `OutputSchema` are `#[non_exhaustive]` with constructors. ### Added - **Telemetry** — `tracing` spans: `agent_loop` (model), `llm_stream` per turn (tokens in/out/cached + `cost_usd` when pricing is configured), and `tool` per execution (name, `is_error`). Bridge to OpenTelemetry app-side with `tracing-opentelemetry`; zero overhead with no subscriber. New `telemetry` example and docs page. - **Cross-provider thinking (7/7)** — `thinking_level` is now honored by every protocol: Gemini and Vertex send `thinkingConfig` (with thought summaries streamed back as `Content::Thinking`), Bedrock sends Anthropic-style `additionalModelRequestFields.thinking` (reasoning deltas and signatures streamed back), Azure sends Responses-style reasoning effort. The "not yet wired" warnings are gone. - **Session trees** — `Session`: branching conversation history with `append`/`seek`/`checkpoint`, fork-preserving edits, `path_messages()` for branch resume, and JSONL persistence. The pi-style id/parent_id tree; maps to GASP's `transcripts/` tier. - **Structured outputs** — `Agent::prompt_structured::(text, schema)` returns a typed, schema-validated reply. Enforcement is native per provider: Anthropic (forced tool call, unwrapped by the loop), OpenAI-compatible (`response_format: json_schema, strict`), Gemini (`responseSchema`). Providers without support log a warning. New `OutputSchema` type on `StreamConfig`/`AgentLoopConfig`; new `StructuredPromptError` with the raw text preserved on parse failure. - **Tool middleware (permissions)** — `ToolMiddleware`, an async approve/deny/modify hook gating every tool call, installed via `Agent::with_tool_middleware` / `SubAgentTool::with_tool_middleware` / `AgentLoopConfig::tool_middleware`. `Deny(reason)` becomes an error tool result the LLM can adapt to (the loop continues); `Modify(args)` rewrites the call. Empty chain = allow all (no behavior change). ## 0.10.0 The headline change is a **config-first construction API**. You now build an agent from a single `ModelConfig` — the provider, model id, context window, and pricing all come from one place, and the API key is resolved from the provider-conventional environment variable. ### Added - `Agent::from_config(ModelConfig)` — the new primary constructor. Selects the built-in provider for `config.api` 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`). - `Agent::from_provider(provider, ModelConfig)` — explicit provider (custom `StreamProvider` impls and test doubles). Pair with `ModelConfig::mock()`. - `Agent::from_config_with(®istry, ModelConfig) -> Result` — resolve the provider from a caller-supplied `ProviderRegistry`. - `Agent::set_model(ModelConfig)` — switch model mid-session. Re-resolves the env key; re-selects the provider only when it was registry-resolved (an explicitly-supplied provider is never silently replaced). - `SubAgentTool::from_config`, `from_config_with`, and `from_provider` mirror the above. - `ModelConfig::mock()` — a throwaway config for tests (use only with `from_provider`). - `AgentBuildError` (exported) — the error type for the fallible `from_config_with` path. - `ProviderRegistry::resolve(&ApiProtocol) -> Option>` and `StreamProvider::protocol() -> Option`. - Automatic env-var API-key resolution and a `with_temperature()` builder (from the 0.9.x adoption-funnel work, now the default construction path). ### Deprecated The following are deprecated since 0.10.0 and will be **removed in 1.0**. They still work; you'll get a compiler warning pointing at the replacement: - `Agent::new`, `Agent::with_model`, `Agent::with_model_config` - `SubAgentTool::new`, `SubAgentTool::with_model`, `SubAgentTool::with_model_config` ### Migration The old builder made you pair a provider with a matching config by hand and pass the model id twice. The new one takes a single config: ```rust // before (0.9): provider and config paired manually; model id passed twice let agent = Agent::new(OpenAiCompatProvider) .with_model_config(ModelConfig::zai("glm-4.7", "GLM 4.7")) .with_model("glm-4.7") .with_api_key(key); // after (0.10): provider inferred from config.api; key from ZAI_API_KEY let agent = Agent::from_config(ModelConfig::zai("glm-4.7", "GLM 4.7")); ``` Per constructor: | Before | After | |---|---| | `Agent::new(AnthropicProvider).with_model("m").with_api_key(k)` | `Agent::from_config(ModelConfig::anthropic("m", "Name")).with_api_key(k)` (drop `with_api_key` to use `ANTHROPIC_API_KEY`) | | `Agent::new(P).with_model_config(cfg).with_model(cfg.id)` | `Agent::from_config(cfg)` | | `Agent::new(customProvider).with_model("m")` | `Agent::from_provider(customProvider, cfg)` | | `Agent::new(MockProvider::text("hi")).with_model("mock")` | `Agent::from_provider(MockProvider::text("hi"), ModelConfig::mock())` | | `SubAgentTool::new(name, provider).with_model_config(cfg)` | `SubAgentTool::from_config(name, cfg)` or `from_provider(name, provider, cfg)` | `with_api_key` is **not** deprecated — keep it wherever you want to pass a key explicitly instead of via the environment. ### Fixed - Google/Vertex usage no longer double-counts cached tokens. - `Retry-After` is clamped to `max_delay_ms`. - Compaction budget calibration subtracts measured overhead instead of scaling by a ratio (the old formula could collapse the budget toward zero). - `session_cost_usd()` returns `None` for unpriced models instead of `0.0`. - Missing API keys now log a warning naming the env var to set.