yologdev/yoagent: v0.25.0
Abstract
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 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.cr