Skip to content

v0.25.0

Choose a tag to compare

@yuanhao yuanhao released this 08 Oct 01:58
· 144 commits to main since this release
ad8c58b

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<E> 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 ToolErrors, 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.