Repository navigation
v0.25.0
Added
-
Extensions (
yoagent::extension, #241): one plug-in contract for the agent lifecycle. AnExtensionis installed withAgent::with_extension/SubAgentTool::with_extension(anArc<E>is one too), and each run gets freshRunHooksfromstart_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 theToolOutput);on_stop(accept, continue or fail the final answer);on_event(every event, in order);finish(with aRunOutcomewhoseend()is aRunEnd).
Advisory or required: a required extension's failure fails the run, which ends with an
Errormessage prefixedEXTENSION_FAILED_PREFIX; tool calls not started yet are not run.Every hook watches the run's cancellation, and
finishis bounded. Partial tool output is withheld while an extension filters tool output. A rechecking policy judges rewritten arguments again.ClonedHookswraps hooks cloned per run.with_tree_extensionapplies an extension to every delegated run at any depth, so host policy reaches sub-agents (a parent'sToolMiddlewarenever did); a custom delegation tool passes the tree on withAgent::delegated_from/AgentLoopConfig::delegated_from, readingToolContext::{tree_extensions, delegation_depth, run_label}. Also new:Agent::with_run_labelandAgent::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 (fromon_event, or background work). The loop asks before each turn, before a response's tools, beforeon_stopand when the run ends, so a required extension's recorded failure fails the run however it ends.yoagent-rutisuses it for pluginon_event/toolsfailures. -
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 withwith_tree_extension;spent_usd()reads the total).Budget::for_modelreturnsNonefor an unpriced model; a negative or NaN limit panics. -
Extension examples:
extension_policy(deny/modify withClonedHooks,rechecks_modified_calls),extension_redact(after_tool+filters_tool_output),extension_verifier(on_stopcontinues,finishreadingRunOutcome::end()),extension_budget(per run and.across_runs()),extension_tree(with_tree_extensionover aSubAgentTooland a hand-writtenAgent::delegated_fromtool) andextension_audit(on_event+finish). Each runs offline on a scriptedMockProviderand checks its own result, and CI runs them all;-- --livedrives a real model (DEEPSEEK_API_KEYorANTHROPIC_API_KEY). Newtests/extension_combos_test.rscovers extensions together with every tool strategy, compaction, steering,prompt_structured, provider retries (withBudget), 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 forinclude: ["reasoning.encrypted_content"], the finished reasoning item is kept on itsContent::Thinkingblock (redactedholds itsid,summaryandencrypted_contentas JSON,redacted_protocolthe API it came from; the readable summary stays inthinking), and the next request sends it back in place as areasoninginput 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 itemid(fc_…/msg_…, recorded in the same JSON ascall_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.includeis 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 afterAgent::set_modeltogpt-4.1the history goes without reasoning items and without item ids; acustomAzure config hasreasoning: false, so set it for a reasoning deployment.storeis 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, needsOPENAI_API_KEY) runs the two-turn exchange live. -
prompt_cache_keyon 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 fromOPENAI_API_KEYat call time,with_api_key/with_base_url/with_retry/with_safety_identifier/with_capabilities) and the presetDecisionModel::gpt_6_luna(); another model id withwith_model, a configured backend withDecisionModel::from_openai_backend. Noul →predicate, Choice →choice, Score →score, question ids →name, a JSON state → pretty-printedinputtext; answers matched byname(by position only when unnamed and one per question). Arefusalanswer fails the call withDecisionError::Refusednaming the question (not retried, and it ends a fallback chain), so the tool gate and the input guard fail closed. Priced, afterprices::enable_bundled(), from the new bundled entryopenai-decisions/gpt-6-luna($0.10 per million input tokens, output free) only while requests go toapi.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; featuredecision): an OpenAI refusal returns it, andDecisionModel::orreturns 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 defaultedCompactionStrategy::reprice;Agent::repricenow 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 onlyYOAGENT_PRICES/install_override/install_fetchedis in effect (no bundled snapshot).LivePricesis#[must_use]. -
SessionStats::compaction(CompactionSpend { usage, cost_usd, requests }) andAgent::compaction_spend(): whatLlmCompaction's summarization requests cost, priced at the summarizer's owncost. See the fix below. -
yoagent-rutis(unpublished): TypeScript and Python hooks get a cancel handle. Every hook buton_eventfinds it in its first argument, as the fieldsignal: a realAbortSignalin JavaScript, rutis'sSignalin 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. Acall_toolthat passescall.signalon (tofetch, a child process, dsh'sexecute) 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'sjson.dumps(call)now refuses the argument (dropsignalfirst). 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.tsoffers every tool of a DeepSeek Harness tool registry (@deepseek-ai/dsh-tools'toolsservice) to yoagent agents:call_toolruns dsh'sexecutewith the bridge's cancel handle as dsh'ssignal, anisErrorresult is an error tool result, andbefore_modeladds 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.jsonpins dsh 0.2.0-rc.2,@deepseek-ai/cordis4.0.4 anddsh-free-search0.8.1, in its own install (the language tests' stays small).examples/dsh_tools.rs(featurenode) runs the unchangeddsh-free-searchplugin'splatform_searchfor a scripted model (it searches the web: needs network) or DeepSeek (--live) and checks its outcome.tests/dsh_test.rscovers 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'srutis-bridge-languagesjob installsplugins/dshtoo. -
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 everyToolDefof rutis-agent'sToolRegistryto a yoagent tool per run (failures areToolErrors, yoagent's cancel token is the one rutis-agent'sexecutewatches), shown with rutis-agent's realreplace_text, a tool registered into the registry while the host runs (offered on the next run), and a cancelled call; scripted, or--livewith 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]forrutis, so the graph holds onerutiscrate.
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-keyheader 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 onExtension(#250).RutisBridge::install(&root)provides ayoagentRegistryservice; plugins register aHandler(a name plus any oftools,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 oneimpl Extension(RutisExtension) whosestart_runsnapshots 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 withwith_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 theevent_sendertee (every run's events are now published on the bus fromon_event, asAgentEventEmittedwithrun_id(),label()anddepth()). 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, itsbefore_tooldenies, itson_inputrejects, itsafter_toolwithholds (without failing arequired()run — an unload is not a failure). A failingon_eventhandler is switched off alone: bus publishing and the other handlers go on, and arequired()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; featuresnode,python,websocket, passed through; the default build stays Rust-only).RutisBridge::installthen also provides the registry as the host serviceyoagent: a plugin callsregister(name, handler, options?)with an object (or Python dict) of async functions passed by reference, and passes the returned unregister function toctx.effect; a runtime that exits or crashes has its handlers removed. Same hooks and plain-JSON arguments as Rust handlers;on_eventis opt-in and filtered by event type (options.events). The handler shape ships asplugins/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'ssignal(see Added); Python coroutines are cancelled too.- The decision features run as extensions (#241 dogfooding).
with_tool_gate,with_input_guardandwith_decision_modelinstall aToolGate(before_tool), anInputGuard(on_input) and the advisor (abefore_modelnote); 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
usagewarns withusage_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
thinkingblock withsignature: "", and Bedrock as unsignedreasoningText(to a non-Claude model); such a block now holds Responses-tagged encrypted reasoning inredacted, 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 presetsjev()/clef()/clef_flash()priced themselves from theprices.jsoncompiled into the crate. NowModelConfig::costisNonefrom every constructor, decision presets are unpriced, and sosession_cost_usd(),total_cost_usd()and thellm_streamspan'scost_usdreport unpriced andBudget::for_modelreturnsNone— until the process opts in, once, before building configs:yoagent::provider::prices::enable_bundled()— offline and reproducible: the bundled snapshot (the sameprices.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 loopbackDecisionModel::logprobs, a zero-ratecostyou set). AYOAGENT_PRICESfile,global::install_overrideandglobal::install_fetchedare still honoured and are opt-ins of their own for exactly the models they list. An explicitcost,ModelConfig::with_prices(&table)andreprice()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 forcloudflare/*entries are no longer reported as inert with thedecisionfeature. -
AgentMessage::Extension(ExtensionMessage)is nowAgentMessage::Custom(CustomMessage)(#228), so it isn't confused withExtension, the plug-in contract. Match arms and constructors need the new names;ExtensionMessageremains as a deprecated alias ofCustomMessage(a variant can't have an alias). Saved data is unchanged:AgentMessageis untagged, so the variant name is never serialized, andCustomMessage::newstill writesrole: "extension". For example, yoyo-evolve matchesAgentMessage::Extensionin five places. Migration:AgentMessage::Extension(m)→AgentMessage::Custom(m),ExtensionMessage→CustomMessage. -
yoagent::provider::sseis private;drive_sseandSseEventare 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. -
AgentLoopConfigis#[non_exhaustive]; build it withAgentLoopConfig::new(provider, model).newsets 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 callingagent_loopdirectly, which is why recent features went through task-locals; new fields (such as#241's extensions) no longer break anyone.AgentandSubAgentToolusers are unaffected. -
ModelConfig::openai_responsesinfersreasoningfrom the model id (#228). It wastruefor every id; it is nowfalseforgpt-3*,gpt-4*(gpt-4o,gpt-4.1, …),chatgpt-*and any id containing-chat(gpt-5-chat-latest), and staystruefor 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", ..)withThinkingLevel::Offwould be sentinclude: ["reasoning.encrypted_content"]and fail with a 400 ("Encrypted content is not supported with this model"). Setconfig.reasoningto override. AThinkingLevelset on one of those ids now logs the existing "not marked reasoning-capable" warning (and still sends the effort). Breaking only if you readconfig.reasoning; set it to override.
Deprecated
- The 0.10-deprecated constructors are hidden from the docs (#228):
Agent::new/with_model/with_model_configand the same onSubAgentToolstill compile (with a deprecation warning) but no longer appear on docs.rs. They will be removed in 1.0, as their notes say. ToolGateas aToolMiddlewareandInputGuardas anAsyncInputFilter. Install them withwith_tool_gate/with_input_guard(or as extensions), and drive them outside the loop with the newToolGate::decide(&call)andInputGuard::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.ExtensionMessageis a deprecated alias ofCustomMessage(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 itsToolContext. -
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 theProviderRetry, itsAbortedmessage arrives as aMessageStartandMessageEndwith no text, also throughretry_safe_events. A retried attempt that failed before any output still gets only itsProviderRetry. -
Every
TurnStarthas aTurnEnd(#243). A turn stopped by an execution limit, or byon_before_turnreturningfalse, 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/MessageEndpair, aTurnEnd) 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_sendermethods run on the same spawned path asprompt/continue_loop(#228). Dropping one of their futures (atokio::time::timeout, aselect!) 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 (withAgentEnd) still reach the sender while theAgentlives (callfinishafter a timeout rather than dropping the agent), and the nextfinishor 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 fixesprompt/continue_loop, whose loop spans used to start a new trace).finishandresetare also safe to cancel now. The four run starters share one implementation, and the stale "misuse-Resultvariant planned for 0.10" notes are gone. -
LlmCompaction's summarization spend now reaches the run's stats (#228). It was reported only onContextCompactedevents and in aninfo!log, soSessionStats,total_usage(),total_cost_usd()andAgent::total_cost_usd()under-counted every session that summarized. The spend is a separate bucket,SessionStats::compaction, sousage,turnsandcost_usdstay the main model's own turns. Details:- Totals change:
total_usage()andtotal_cost_usd()(onSessionStatsandAgent) now include it, so they are higher for sessions that summarized; with a summarizer that has no price (cost: None),total_cost_usd()is nowNonerather 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 (
Agentkeeps one strategy across runs). Each request is counted once, whether its briefing was spliced, discarded or rejected (aLengthorRefusalsummary 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
compactionbucket (one bucket for the delegation tree, likedecision), not intosub_agents.extension::Budgetcounts 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, soBudgetprices 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
SessionStatsstill deserialize.
- Totals change:
-
extension::Budgetcounts the run's own decision and compaction spend — tool gate, input guard and advisor evaluations, andLlmCompactionsummaries — 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_keyfails before sending (OpenAiDecisionBackend,SystemOneBackend,LogprobBackend,DecisionModel::with_api_key):MissingApiKeyinstead of sendingBearer. -
An
Agentrun carries the caller's tracing subscriber into its spawned task (and the extension observer and compaction tasks do too), so a subscriber set withtracing::subscriber::set_default/with_defaultsees 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.doneends the previous item's range. -
yoagent-rutis: a TypeScript/Python handler's
on_eventdelivery no longer fails silently. A panic during delivery is caught, and a delivery task that ended (seen onsendorflush) 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_toolis 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_filesreports what it couldn't read (#260, found by yoyo).findskips 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 underWarningsin the text and indetails.warnings(capped); a listing with no files and an error fails, assearchdoes.