yoagent
Simple, effective agent loop in Rust.
yoagent is a library for building LLM-powered agents that can use tools. It provides the core loop — prompt the model, execute tool calls, feed results back — and gets out of your way.
Philosophy
The loop is the product. An agent is just a loop: send messages to an LLM, get back text and tool calls, execute the tools, repeat until the model stops. yoagent implements this loop with streaming, cancellation, context management, and multi-provider support — so you don't have to.
Features
- Streaming events — Real-time
AgentEventstream for UI updates (text deltas, thinking, tool execution) - Multi-provider — Anthropic, OpenAI, Google Gemini, Amazon Bedrock, Azure OpenAI, and any OpenAI-compatible API
- Tool system —
AgentTooltrait with built-in coding tools (bash, file read/write/edit, search) - Context management — Automatic token estimation, tiered compaction (truncate tool outputs → summarize → drop old messages)
- Execution limits — Max turns, tokens, and wall-clock time
- Steering & follow-ups — Interrupt the agent mid-run or queue work for after it finishes
- Cancellation —
CancellationToken-based abort at any point - Builder pattern — Ergonomic
Agentstruct with chainable configuration
Ecosystem
yoagent is part of the Yolog ecosystem. It powers the agent backend for Yolog applications.
- Repository: github.com/yologdev/yoagent
- License: MIT
Installation
Requirements
- Rust 1.86+ (2021 edition)
- Tokio async runtime
Add to Cargo.toml
[dependencies]
yoagent = "0.12"
tokio = { version = "1", features = ["full"] }
Dependencies
yoagent brings in these key dependencies automatically:
| Crate | Purpose |
|---|---|
tokio | Async runtime (full features) |
serde / serde_json | Serialization |
reqwest | HTTP client for provider APIs |
reqwest-eventsource | SSE streaming |
async-trait | Async trait support |
tokio-util | CancellationToken |
thiserror | Error types |
tracing | Logging |
Feature Flags
All providers and built-in tools are included by default. Optional features:
| Feature | Dependencies | Description |
|---|---|---|
openapi | openapiv3, serde_yaml_ng | Auto-generate tools from OpenAPI 3.0 specs |
Enable in Cargo.toml:
[dependencies]
yoagent = { version = "0.12", features = ["openapi"] }
Quick Start
Basic Example with Anthropic
use yoagent::{Agent, AgentEvent, StreamDelta}; use yoagent::provider::ModelConfig; use yoagent::tools::default_tools; #[tokio::main] async fn main() { // The provider is selected from the config's protocol, and the API key is // read from ANTHROPIC_API_KEY. Add `.with_api_key(key)` to override it. let mut agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Sonnet 5")) .with_system_prompt("You are a helpful coding assistant.") .with_tools(default_tools()); let mut rx = agent.prompt("List the files in the current directory").await; while let Some(event) = rx.recv().await { match event { AgentEvent::MessageUpdate { delta, .. } => match delta { StreamDelta::Text { delta } => print!("{}", delta), StreamDelta::Thinking { delta } => print!("[thinking] {}", delta), _ => {} }, AgentEvent::ToolExecutionStart { tool_name, .. } => { println!("\n→ Running tool: {}", tool_name); } AgentEvent::ToolExecutionEnd { tool_name, result, is_error, .. } => { if is_error { println!(" ✗ {} failed", tool_name); } else { println!(" ✓ {} done", tool_name); } } AgentEvent::AgentEnd { .. } => { println!("\n\nDone."); } _ => {} } } }
Example with OpenAI-Compatible Provider
For OpenAI, xAI, Groq, DeepSeek, Mistral, MiniMax, Z.ai, Qwen, Ollama, or any compatible API, pass the matching ModelConfig preset — from_config picks the OpenAI-compatible provider and resolves the provider's env key automatically:
use yoagent::{Agent, AgentEvent}; use yoagent::provider::ModelConfig; use yoagent::tools::default_tools; #[tokio::main] async fn main() { // Provider inferred from the preset; key from OPENAI_API_KEY. let mut agent = Agent::from_config(ModelConfig::openai("gpt-5.5", "GPT-5.5")) .with_system_prompt("You are a helpful assistant.") .with_tools(default_tools()); let mut rx = agent.prompt("What is 2 + 2?").await; while let Some(event) = rx.recv().await { match event { AgentEvent::MessageUpdate { delta, .. } => { if let yoagent::StreamDelta::Text { delta } = delta { print!("{}", delta); } } AgentEvent::AgentEnd { .. } => println!(), _ => {} } } }
Real-Time Streaming
agent.prompt() spawns the agent loop concurrently and returns a receiver immediately, so events stream in real-time as they're produced. For cases where you want to provide your own channel (e.g., to share a sender across tasks), use prompt_with_sender():
use yoagent::{Agent, AgentEvent, StreamDelta}; use yoagent::provider::ModelConfig; use yoagent::tools::default_tools; #[tokio::main] async fn main() { let mut agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Sonnet 5")) .with_system_prompt("You are a helpful assistant.") .with_tools(default_tools()); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); // Consume events in real-time on a separate task tokio::spawn(async move { while let Some(event) = rx.recv().await { match event { AgentEvent::MessageUpdate { delta, .. } => { if let StreamDelta::Text { delta } = delta { print!("{}", delta); } } AgentEvent::AgentEnd { .. } => println!(), _ => {} } } }); // This blocks until the loop finishes; state is restored automatically agent.prompt_with_sender("What is 2 + 2?", tx).await; // Agent is ready for another prompt immediately let _rx = agent.prompt("Follow up question").await; }
Using the Low-Level API
For more control, use agent_loop() directly:
use yoagent::agent_loop::{agent_loop, AgentLoopConfig}; use yoagent::provider::AnthropicProvider; use yoagent::types::*; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; #[tokio::main] async fn main() { let (tx, mut rx) = mpsc::unbounded_channel(); let cancel = CancellationToken::new(); let mut context = AgentContext { system_prompt: "You are helpful.".into(), messages: Vec::new(), tools: yoagent::tools::default_tools(), }; let config = AgentLoopConfig { provider: std::sync::Arc::new(AnthropicProvider), model: "claude-sonnet-5".into(), api_key: std::env::var("ANTHROPIC_API_KEY").unwrap(), thinking_level: ThinkingLevel::Off, max_tokens: None, temperature: None, model_config: None, convert_to_llm: None, transform_context: None, get_steering_messages: None, get_follow_up_messages: None, context_config: None, compaction_strategy: None, execution_limits: None, cache_config: CacheConfig::default(), tool_execution: ToolExecutionStrategy::default(), retry_config: yoagent::RetryConfig::default(), before_turn: None, after_turn: None, on_error: None, input_filters: vec![], }; let prompts = vec![AgentMessage::Llm(Message::user("Hello!"))]; let new_messages = agent_loop(prompts, &mut context, &config, tx, cancel).await; // Drain events while let Ok(event) = rx.try_recv() { // handle events... } println!("Got {} new messages", new_messages.len()); }
The Agent Loop
The agent loop is the core of yoagent. It implements the fundamental cycle:
User prompt → LLM call → Tool execution → LLM call → ... → Final response
How It Works
┌──────────────────────────────────────────────┐
│ agent_loop() │
│ │
│ 1. Add prompts to context │
│ 2. Emit AgentStart + TurnStart │
│ │
│ ┌─────────── Inner Loop ──────────────┐ │
│ │ • Check steering messages │ │
│ │ • Check execution limits │ │
│ │ • Compact context (if configured) │ │
│ │ • Stream LLM response │ │
│ │ • Extract tool calls │ │
│ │ • Execute tools (with steering) │ │
│ │ • Emit TurnEnd │ │
│ │ • Continue if tool_calls or steer │ │
│ └─────────────────────────────────────┘ │
│ │
│ 3. Check follow-up messages │
│ 4. If follow-ups exist, loop again │
│ 5. Emit AgentEnd │
└──────────────────────────────────────────────┘
Entry Points
agent_loop()
Starts a new agent run with prompt messages:
#![allow(unused)] fn main() { pub async fn agent_loop( prompts: Vec<AgentMessage>, context: &mut AgentContext, config: &AgentLoopConfig, tx: mpsc::UnboundedSender<AgentEvent>, cancel: CancellationToken, ) -> Vec<AgentMessage> }
The prompts are added to context, then the loop runs. Returns all new messages generated during the run.
agent_loop_continue()
Resumes from existing context (e.g., after an error or retry):
#![allow(unused)] fn main() { pub async fn agent_loop_continue( context: &mut AgentContext, config: &AgentLoopConfig, tx: mpsc::UnboundedSender<AgentEvent>, cancel: CancellationToken, ) -> Vec<AgentMessage> }
Requires that the last message in context is not an assistant message.
AgentLoopConfig
#![allow(unused)] fn main() { pub struct AgentLoopConfig { pub provider: Arc<dyn StreamProvider>, pub model: String, pub api_key: String, pub thinking_level: ThinkingLevel, pub max_tokens: Option<u32>, pub temperature: Option<f32>, pub model_config: Option<ModelConfig>, pub convert_to_llm: Option<ConvertToLlmFn>, pub transform_context: Option<TransformContextFn>, pub get_steering_messages: Option<GetMessagesFn>, pub get_follow_up_messages: Option<GetMessagesFn>, pub context_config: Option<ContextConfig>, pub execution_limits: Option<ExecutionLimits>, pub cache_config: CacheConfig, pub tool_execution: ToolExecutionStrategy, pub retry_config: RetryConfig, pub before_turn: Option<BeforeTurnFn>, pub after_turn: Option<AfterTurnFn>, pub on_error: Option<OnErrorFn>, pub input_filters: Vec<Arc<dyn InputFilter>>, pub compaction_strategy: Option<Arc<dyn CompactionStrategy>>, pub turn_delay: Option<Duration>, } }
| Field | Purpose |
|---|---|
provider | The StreamProvider implementation to use |
model | Model identifier (e.g., "claude-sonnet-5") |
api_key | API key for the provider |
thinking_level | Off, Minimal, Low, Medium, High |
model_config | Optional ModelConfig for multi-provider support (base URL, headers, compat flags) |
convert_to_llm | Custom AgentMessage[] → Message[] conversion |
transform_context | Pre-processing hook for context pruning |
get_steering_messages | Returns user interruptions during tool execution |
get_follow_up_messages | Returns queued work after agent would stop |
context_config | Token budget and compaction settings. Auto-derived from model_config.context_window (80%) when not set |
execution_limits | Max turns, tokens, duration |
cache_config | Prompt caching behavior (see Prompt Caching) |
tool_execution | Parallel, Sequential, or Batched (see Tools) |
retry_config | Retry behavior for transient errors (see Retry) |
before_turn | Called before each LLM call; return false to abort (see Callbacks) |
after_turn | Called after each turn with messages and usage (see Callbacks) |
on_error | Called on StopReason::Error with the error string (see Callbacks) |
input_filters | Input filters applied to user messages before the LLM call (see Tools) |
compaction_strategy | Custom compaction strategy (see Custom Compaction below) |
turn_delay | Optional inter-turn delay to throttle API calls. Skips the first turn. Useful for rate-limit-sensitive providers (e.g., OAuth tokens with low RPM caps) |
Steering & Follow-Ups
Steering
Steering messages interrupt the agent between tool executions. When the agent is executing multiple tool calls from a single LLM response, steering is checked after each tool completes. If a steering message is found:
- The current tool finishes normally
- All remaining tool calls are skipped with
is_error: trueand "Skipped due to queued user message" - The steering message is injected into context
- The loop continues with a new LLM call that sees the interruption
#![allow(unused)] fn main() { // While agent is running tools, redirect it: agent.steer(AgentMessage::Llm(Message::user("Stop that. Instead, explain what you found."))); }
Follow-Ups
Follow-up messages are checked after the agent would normally stop (no more tool calls, no steering). If follow-ups exist, the loop continues with them as new input — the agent doesn't need to be re-prompted.
#![allow(unused)] fn main() { // Queue work for after the agent finishes its current task: agent.follow_up(AgentMessage::Llm(Message::user("Now run the tests."))); agent.follow_up(AgentMessage::Llm(Message::user("Then commit the changes."))); }
Queue Modes
Both queues support two delivery modes:
| Mode | Behavior |
|---|---|
QueueMode::OneAtATime | Delivers one message per turn (default) |
QueueMode::All | Delivers all queued messages at once |
#![allow(unused)] fn main() { agent.set_steering_mode(QueueMode::All); agent.set_follow_up_mode(QueueMode::OneAtATime); }
Queue Management
#![allow(unused)] fn main() { agent.clear_steering_queue(); // Drop all pending steers agent.clear_follow_up_queue(); // Drop all pending follow-ups agent.clear_all_queues(); // Drop everything }
Clients that render the queue (e.g. showing pending messages while the agent is busy) can inspect it without consuming:
#![allow(unused)] fn main() { let pending = agent.steering_queue_snapshot(); // point-in-time copy let count = pending.len(); }
Use steering_queue_len() instead when only the count is needed — it avoids
cloning message contents, which matters for high-frequency polling.
For an edit-and-requeue UI, drain atomically and requeue the survivors as a single batch:
#![allow(unused)] fn main() { let mut queued = agent.take_steering_queue(); // atomic drain queued.pop(); // e.g. drop the newest entry agent.steer_all(queued); // requeue the rest under one lock }
These accessors pair with the spawn-based flows (prompt(),
prompt_messages(), continue_loop()). The _with_sender variants borrow
the agent mutably for the whole run, so the queue cannot be touched while one
is in flight.
Only the drain is atomic — the edit round trip is not:
- The loop keeps running while the queue is taken. If the run finishes during the edit, requeued survivors are delivered at the start of the next run.
- A message the loop has already picked up for injection is no longer in
the queue: snapshots won't show it and
take_steering_queue()cannot retract it. - Messages steered concurrently during the edit window land ahead of
requeued survivors (
steerappends to the back; delivery pops the front). - Delivery of a requeued batch follows the steering
QueueMode:Allinjects it at one check, the defaultOneAtATimedelivers one message per check. - After
reset(), discard taken messages instead of requeueing them. - Pending queue contents are not included in
save_messages()persistence — snapshot and store them separately if they must survive a restart.
Low-Level API
When using agent_loop() directly, steering and follow-ups are provided via callback functions:
#![allow(unused)] fn main() { let config = AgentLoopConfig { get_steering_messages: Some(Box::new(|| { // Return Vec<AgentMessage> — checked between tool calls vec![] })), get_follow_up_messages: Some(Box::new(|| { // Return Vec<AgentMessage> — checked when agent would stop vec![] })), // ... }; }
Custom Compaction
By default, when context exceeds the token budget in ContextConfig, yoagent runs a 3-level compaction strategy: truncate tool outputs → summarize old turns → drop middle messages. You can replace this with your own CompactionStrategy:
#![allow(unused)] fn main() { use yoagent::context::{CompactionStrategy, ContextConfig, compact_messages}; use yoagent::types::*; struct MyCompaction; impl CompactionStrategy for MyCompaction { fn compact( &self, messages: Vec<AgentMessage>, config: &ContextConfig, ) -> Vec<AgentMessage> { // Your logic here — then optionally delegate to the default: compact_messages(messages, config) } } let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_compaction_strategy(MyCompaction); }
The strategy is called once per turn, right before the LLM call, whenever context_config is Some. When compaction_strategy is None, DefaultCompaction (which wraps compact_messages()) is used automatically.
Use Cases
Memory-aware compaction — Index messages into a vector store before they're dropped, so the agent can recall them later via a search tool:
#![allow(unused)] fn main() { struct MemoryAwareCompaction { memory: Arc<dyn MemoryStore>, } impl CompactionStrategy for MemoryAwareCompaction { fn compact( &self, messages: Vec<AgentMessage>, config: &ContextConfig, ) -> Vec<AgentMessage> { let compacted = compact_messages(messages.clone(), config); // Index what was dropped let dropped: Vec<_> = messages.iter() .filter(|m| !compacted.contains(m)) .collect(); if !dropped.is_empty() { self.memory.index(dropped); } compacted } } }
Semantic pointer compaction — Replace dropped messages with a marker so the agent knows context was lost:
#![allow(unused)] fn main() { struct SemanticPointerCompaction; impl CompactionStrategy for SemanticPointerCompaction { fn compact( &self, messages: Vec<AgentMessage>, config: &ContextConfig, ) -> Vec<AgentMessage> { let compacted = compact_messages(messages.clone(), config); let dropped_count = messages.len() - compacted.len(); if dropped_count == 0 { return compacted; } // Insert a marker after the first kept messages let mut result = compacted; let insert_at = config.keep_first.min(result.len()); result.insert(insert_at, AgentMessage::Extension( ExtensionMessage::new("compaction_marker", serde_json::json!({ "dropped": dropped_count, "note": format!("{} earlier messages were compacted", dropped_count), })) )); result } } }
Priority-preserving compaction — Never drop messages containing important keywords:
#![allow(unused)] fn main() { struct PriorityPreservingCompaction { preserve_keywords: Vec<String>, } impl CompactionStrategy for PriorityPreservingCompaction { fn compact( &self, messages: Vec<AgentMessage>, config: &ContextConfig, ) -> Vec<AgentMessage> { let (priority, normal): (Vec<_>, Vec<_>) = messages.into_iter() .partition(|m| self.is_priority(m)); let mut compacted = compact_messages(normal, config); // Re-insert priority messages — they're never dropped for msg in priority { compacted.push(msg); } compacted } } }
Messages & Events
Message Types
Message
The core LLM message type, tagged by role:
#![allow(unused)] fn main() { pub enum Message { User { content: Vec<Content>, timestamp: u64, }, Assistant { content: Vec<Content>, stop_reason: StopReason, model: String, provider: String, usage: Usage, timestamp: u64, error_message: Option<String>, }, ToolResult { tool_call_id: String, tool_name: String, content: Vec<Content>, is_error: bool, timestamp: u64, }, } }
Create user messages easily:
#![allow(unused)] fn main() { let msg = Message::user("Hello, world!"); }
AgentMessage
Wraps Message with support for extension messages (UI-only, notifications, etc.):
#![allow(unused)] fn main() { pub enum AgentMessage { Llm(Message), Extension(ExtensionMessage), } pub struct ExtensionMessage { pub role: String, pub kind: String, pub data: serde_json::Value, } }
Create extension messages with the convenience constructor:
#![allow(unused)] fn main() { let ext = ExtensionMessage::new("status_update", serde_json::json!({"status": "running"})); let msg = AgentMessage::Extension(ext); }
The kind field categorizes the extension (e.g., "status_update", "ui_event", "notification"). Use as_llm() to extract the Message if it's an LLM message. The default convert_to_llm function filters out Extension messages before sending to the provider.
All core message types implement Serialize, Deserialize, Clone, and PartialEq, enabling state persistence and test assertions.
Content
Each message contains Vec<Content>:
#![allow(unused)] fn main() { pub enum Content { Text { text: String }, Image { data: String, mime_type: String }, Thinking { thinking: String, signature: Option<String> }, ToolCall { id: String, name: String, arguments: serde_json::Value, provider_metadata: Option<serde_json::Value>, // e.g. Gemini thought signatures }, } }
An assistant message can contain multiple content blocks — e.g., thinking + text + tool calls.
Content is #[non_exhaustive] (match with a wildcard arm), and the ToolCall and Thinking variants are separately #[non_exhaustive] — construct them via Content::tool_call() / tool_call_with_metadata() / thinking() / thinking_signed(). Message::Assistant is likewise #[non_exhaustive]; custom providers construct it via Message::assistant().
StopReason
#![allow(unused)] fn main() { pub enum StopReason { Stop, // Natural completion Length, // Hit max tokens ToolUse, // Wants to call tools Error, // Provider error Aborted, // Cancelled by user Refusal, // Declined by the provider's safety system } }
Usage
Token usage from the provider:
#![allow(unused)] fn main() { pub struct Usage { pub input: u64, pub output: u64, pub cache_read: u64, pub cache_write: u64, pub total_tokens: u64, } }
AgentEvent
Events emitted during the agent loop for real-time UI updates:
| Event | When |
|---|---|
AgentStart | Loop begins |
AgentEnd { messages } | Loop finishes, all new messages |
TurnStart | New LLM call starting |
TurnEnd { message, tool_results } | LLM call + tool execution complete |
MessageStart { message } | A message is available |
MessageUpdate { message, delta } | Streaming delta arrived |
MessageEnd { message } | Message finalized |
ToolExecutionStart { tool_call_id, tool_name, args } | Tool about to run |
ToolExecutionUpdate { tool_call_id, tool_name, partial_result } | Tool progress |
ToolExecutionEnd { tool_call_id, tool_name, result, is_error } | Tool finished |
ProgressMessage { tool_call_id, tool_name, text } | User-facing progress text from a tool |
InputRejected { reason } | Input filter rejected the user's message |
Wire format
AgentEvent and StreamDelta serialize as internally-tagged camelCase JSON, so
external frontends (a websocket fanout server, a TypeScript client, a JSONL
pipe) can consume the event stream directly:
{"type":"messageUpdate","message":{...},"delta":{"type":"text","delta":"hi"}}
{"type":"toolExecutionEnd","toolCallId":"tc_1","toolName":"bash","result":{...},"isError":false}
This shape is a public contract frozen by snapshot tests — variant tags, field names, and the tagging scheme won't change in minor releases.
Streaming semantics: clients accumulate text from each MessageUpdate's
delta; the message field during streaming is an empty-content
placeholder (the complete message arrives as a new value in MessageEnd).
Reset accumulation on each MessageStart — after a transient provider
error the stream restarts from a fresh MessageStart with no closing
MessageEnd for the abandoned attempt, so a client that doesn't reset
duplicates the replayed text. A client that misses events entirely (e.g. a
lagged websocket subscriber) resyncs from the next MessageEnd without
replay.
StreamDelta
Deltas within MessageUpdate:
#![allow(unused)] fn main() { pub enum StreamDelta { Text { delta: String }, Thinking { delta: String }, ToolCallDelta { delta: String }, } }
Agent State
The Agent struct provides access to its current state:
#![allow(unused)] fn main() { // Check if the agent is currently streaming a response if agent.is_streaming() { // Use steer() or follow_up() instead of prompt() agent.steer(AgentMessage::Llm(Message::user("New instruction"))); } // Access the full message history let messages: &[AgentMessage] = agent.messages(); // Check the last message if let Some(last) = messages.last() { println!("Last message role: {}", last.role()); } }
The is_streaming() flag is true between prompt()/continue_loop() call and completion. While streaming, calling prompt() will panic — use steer() or follow_up() instead.
Tools
The AgentTool Trait
Every tool implements AgentTool:
#![allow(unused)] fn main() { #[async_trait] pub trait AgentTool: Send + Sync { fn name(&self) -> &str; fn label(&self) -> &str; fn description(&self) -> &str; fn parameters_schema(&self) -> serde_json::Value; async fn execute( &self, params: serde_json::Value, ctx: ToolContext, ) -> Result<ToolResult, ToolError>; } }
| Method | Purpose |
|---|---|
name() | Unique ID sent to LLM (e.g., "bash") |
label() | Human-readable name for UI (e.g., "Run Command") |
description() | Tells the LLM what the tool does |
parameters_schema() | JSON Schema for the tool's parameters |
execute() | Runs the tool, returns ToolResult or ToolError. Receives a ToolContext with cancellation, update, and progress callbacks. |
ToolContext
All execution context is bundled into a single struct, making the trait easier to extend in the future:
#![allow(unused)] fn main() { pub struct ToolContext { pub tool_call_id: String, pub tool_name: String, pub cancel: CancellationToken, pub on_update: Option<ToolUpdateFn>, pub on_progress: Option<ProgressFn>, } }
| Field | Purpose |
|---|---|
tool_call_id | Unique ID for this tool call (for correlating events) |
tool_name | Name of the tool being executed |
cancel | Cancellation token — check ctx.cancel.is_cancelled() in long-running tools |
on_update | Callback for streaming partial ToolResult updates to the UI (emits ToolExecutionUpdate) |
on_progress | Callback for emitting user-facing progress messages (emits ProgressMessage) |
ToolContext implements Clone and Debug.
ToolResult
#![allow(unused)] fn main() { pub struct ToolResult { pub content: Vec<Content>, pub details: serde_json::Value, } }
The content is sent back to the LLM. The details field holds metadata (not sent to the LLM) for UI/logging.
ToolError
#![allow(unused)] fn main() { pub enum ToolError { Failed(String), NotFound(String), InvalidArgs(String), Cancelled, } }
Errors are converted to ToolResult with is_error: true and sent back to the LLM so it can recover.
Implementing a Custom Tool
#![allow(unused)] fn main() { use yoagent::types::*; use async_trait::async_trait; pub struct WeatherTool; #[async_trait] impl AgentTool for WeatherTool { fn name(&self) -> &str { "get_weather" } fn label(&self) -> &str { "Weather" } fn description(&self) -> &str { "Get current weather for a city." } fn parameters_schema(&self) -> serde_json::Value { serde_json::json!({ "type": "object", "properties": { "city": { "type": "string", "description": "City name" } }, "required": ["city"] }) } async fn execute( &self, params: serde_json::Value, _ctx: ToolContext, ) -> Result<ToolResult, ToolError> { let city = params["city"].as_str() .ok_or(ToolError::InvalidArgs("missing city".into()))?; // Call weather API... Ok(ToolResult { content: vec![Content::Text { text: format!("Weather in {}: 72°F, sunny", city), }], details: serde_json::Value::Null, }) } } }
Register custom tools alongside defaults:
#![allow(unused)] fn main() { use yoagent::tools::default_tools; let mut tools = default_tools(); tools.push(Box::new(WeatherTool)); let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")).with_tools(tools); }
Error Handling
Return Err(ToolError) on failure, not Ok with error text. When a tool returns Err, the agent loop converts it to a Message::ToolResult with is_error: true and sends it to the LLM. The LLM sees the error and can self-correct — retry with different arguments, try a different approach, or explain the failure to the user.
#![allow(unused)] fn main() { async fn execute(&self, params: serde_json::Value, _ctx: ToolContext) -> Result<ToolResult, ToolError> { let path = params["path"].as_str() .ok_or(ToolError::InvalidArgs("missing 'path'".into()))?; let content = std::fs::read_to_string(path) .map_err(|e| ToolError::Failed(format!("Cannot read {}: {}", path, e)))?; Ok(ToolResult { content: vec![Content::Text { text: content }], details: serde_json::Value::Null, }) } }
Exception: BashTool. The built-in BashTool returns Ok even on non-zero exit codes, with both stdout and stderr in the result. This is intentional — the LLM needs to see the actual error output (compilation errors, test failures, etc.) to diagnose and fix issues. Only truly exceptional failures (e.g., command not found, cancellation) return Err.
Tool Execution Flow
- LLM returns
Content::ToolCallblocks in its response - Agent loop emits
ToolExecutionStartfor each - Tool's
execute()is called with parsed arguments - Result (or error) is wrapped in
Message::ToolResult ToolExecutionEndis emitted- All tool results are added to context
- Loop continues with another LLM call
Streaming Tool Output
Long-running tools can stream progress updates to the UI via the on_update callback. Each call emits a ToolExecutionUpdate event. Partial results are for UI/logging only — they are not sent to the LLM. Only the final ToolResult returned from execute() becomes part of the conversation.
The ToolUpdateFn type
#![allow(unused)] fn main() { pub type ToolUpdateFn = Arc<dyn Fn(ToolResult) + Send + Sync>; }
Basic usage
Call on_update whenever you have progress to report:
#![allow(unused)] fn main() { use yoagent::types::*; struct DataProcessorTool; #[async_trait] impl AgentTool for DataProcessorTool { // ... name, label, description, parameters_schema ... async fn execute( &self, params: serde_json::Value, ctx: ToolContext, ) -> Result<ToolResult, ToolError> { let rows = fetch_rows(¶ms)?; let total = rows.len(); for (i, row) in rows.iter().enumerate() { // Check for cancellation if ctx.cancel.is_cancelled() { return Err(ToolError::Cancelled); } process_row(row); // Stream progress every 100 rows if i % 100 == 0 { if let Some(ref cb) = &ctx.on_update { cb(ToolResult { content: vec![Content::Text { text: format!("Processed {}/{} rows", i, total), }], details: serde_json::json!({"progress": i as f64 / total as f64}), }); } } } Ok(ToolResult { content: vec![Content::Text { text: format!("Processed all {} rows", total), }], details: serde_json::Value::Null, }) } } }
Consuming updates in your UI
Updates arrive as AgentEvent::ToolExecutionUpdate events on the same event stream as all other agent events:
#![allow(unused)] fn main() { while let Some(event) = rx.recv().await { match event { AgentEvent::ToolExecutionStart { tool_name, .. } => { println!("⏳ {} started", tool_name); } AgentEvent::ToolExecutionUpdate { tool_name, partial_result, .. } => { // Show progress in your UI if let Some(Content::Text { text }) = partial_result.content.first() { println!(" 📊 {}: {}", tool_name, text); } } AgentEvent::ToolExecutionEnd { tool_name, is_error, .. } => { println!("{} {}", if is_error { "❌" } else { "✅" }, tool_name); } AgentEvent::ProgressMessage { tool_name, text, .. } => { println!(" 💬 {}: {}", tool_name, text); } _ => {} } } }
Progress Messages
In addition to on_update (which streams partial ToolResult values), tools can emit lightweight text-only progress messages via ctx.on_progress. These appear as AgentEvent::ProgressMessage events:
#![allow(unused)] fn main() { async fn execute(&self, params: serde_json::Value, ctx: ToolContext) -> Result<ToolResult, ToolError> { if let Some(ref progress) = &ctx.on_progress { progress("Starting analysis...".into()); } // ... do work ... if let Some(ref progress) = &ctx.on_progress { progress("Almost done...".into()); } Ok(ToolResult { /* ... */ }) } }
Use on_progress for simple status text. Use on_update when you need structured data (progress percentages, partial results).
Guidelines
- Call
on_updateas often as useful — there's no rate limit. The callback is synchronous and cheap. - Always check
ctx.on_update.is_some()before building theToolResult. IfNone, the loop isn't interested in updates (e.g., testing). - Use
detailsfor structured data —contentis for human-readable text,detailscan carry progress percentages, byte counts, etc. - Don't rely on updates reaching the LLM — they won't. Only the final return value is added to context.
- Simple tools don't need it — if your tool completes in <1 second, just ignore
ctx(prefix with_ctxto suppress the warning).
End-to-end example
Here's a complete example: a CLI agent with a deploy tool that streams progress. The human sees real-time output while the LLM only gets the final result.
use yoagent::agent::Agent; use yoagent::provider::ModelConfig; use yoagent::types::*; /// A tool that deploys an app and streams each step. struct DeployTool; #[async_trait] impl AgentTool for DeployTool { fn name(&self) -> &str { "deploy" } fn label(&self) -> &str { "Deploy App" } fn description(&self) -> &str { "Deploy the application to production." } fn parameters_schema(&self) -> serde_json::Value { serde_json::json!({ "type": "object", "properties": { "env": { "type": "string", "description": "Target environment" } }, "required": ["env"] }) } async fn execute( &self, params: serde_json::Value, ctx: ToolContext, ) -> Result<ToolResult, ToolError> { let env = params["env"].as_str().unwrap_or("staging"); let steps = ["Building image", "Running tests", "Pushing to registry", "Rolling out"]; for (i, step) in steps.iter().enumerate() { if ctx.cancel.is_cancelled() { return Err(ToolError::Cancelled); } // Stream each step to the UI if let Some(ref cb) = &ctx.on_update { cb(ToolResult { content: vec![Content::Text { text: format!("[{}/{}] {}...", i + 1, steps.len(), step), }], details: serde_json::json!({ "step": i + 1, "total": steps.len(), "phase": step, }), }); } // Simulate work tokio::time::sleep(std::time::Duration::from_secs(2)).await; } // Only this final result is sent to the LLM Ok(ToolResult { content: vec![Content::Text { text: format!("Successfully deployed to {}", env), }], details: serde_json::json!({"env": env, "status": "success"}), }) } } #[tokio::main] async fn main() { let mut agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_system_prompt("You are a deployment assistant.") .with_tools(vec![Box::new(DeployTool)]); let mut rx = agent.prompt("Deploy to production").await; while let Some(event) = rx.recv().await { match event { // LLM text streaming AgentEvent::MessageUpdate { delta: StreamDelta::Text { delta }, .. } => print!("{}", delta), // Tool progress streaming AgentEvent::ToolExecutionStart { tool_name, .. } => { println!("\n🚀 Starting {}...", tool_name); } AgentEvent::ToolExecutionUpdate { partial_result, .. } => { if let Some(Content::Text { text }) = partial_result.content.first() { println!(" {}", text); } } AgentEvent::ToolExecutionEnd { tool_name, is_error, .. } => { if is_error { println!(" ❌ {} failed", tool_name); } else { println!(" ✅ {} complete", tool_name); } } AgentEvent::ProgressMessage { text, .. } => { println!(" 💬 {}", text); } AgentEvent::AgentEnd { .. } => break, _ => {} } } }
Running this produces:
🚀 Starting deploy...
[1/4] Building image...
[2/4] Running tests...
[3/4] Pushing to registry...
[4/4] Rolling out...
✅ deploy complete
Successfully deployed to production. The deployment completed all 4 stages.
The human sees each step as it happens. The LLM only sees "Successfully deployed to production" and can continue the conversation from there.
How agents benefit
When an AI agent (like a coding assistant) uses yoagent, streaming tool output helps in two ways:
-
Human oversight — The human watching the agent work sees real-time progress instead of waiting for a tool to finish. A bash command running
cargo buildcan stream compiler output as it happens, so the human can interrupt early if something is wrong. -
Agent UIs — Tools like web dashboards, IDE extensions, or chat interfaces can render live progress bars, log tails, or status indicators. The
detailsfield inToolResultcarries structured data (progress percentage, byte counts, etc.) that UIs can render however they want.
The LLM itself doesn't see updates — it works with final results only. This is intentional: partial output would waste context tokens and confuse the model. The streaming is purely a human-facing feature.
Execution Strategies
When the LLM returns multiple tool calls in a single response (e.g., "read file A, read file B, run bash C"), ToolExecutionStrategy controls how they run:
| Strategy | Behavior |
|---|---|
Sequential | One at a time. Steering checked between each tool. Use for debugging or tools with shared mutable state. |
Parallel (default) | All tool calls run concurrently via futures::join_all. Steering checked after all complete. Best latency for independent tools. |
Batched { size } | Run in groups of N. Steering checked between batches. Balances speed with human-in-the-loop control. |
Configuration
#![allow(unused)] fn main() { use yoagent::agent::Agent; use yoagent::types::ToolExecutionStrategy; // Default — parallel (fastest) let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")); // Sequential (debug / shared state) let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_tool_execution(ToolExecutionStrategy::Sequential); // Batched — 3 at a time let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_tool_execution(ToolExecutionStrategy::Batched { size: 3 }); }
When to use each
- Parallel (default): Most tool calls are independent — file reads, searches, API calls. Running them concurrently can cut latency dramatically (3 tools × 50ms = ~50ms instead of ~150ms).
- Sequential: When tools have side effects that depend on order, or when you need fine-grained steering control between each tool.
- Batched: When you want parallelism but also want steering checkpoints. For example,
Batched { size: 3 }runs 3 tools concurrently, checks for user interrupts, then runs the next 3.
Steering messages are always checked between execution units (between each tool in Sequential, after all tools in Parallel, between batches in Batched). If a user interrupts, remaining tools are skipped.
Permissions: Tool Middleware
Every tool call can be gated by an async middleware chain — the mechanism behind permission prompts, policy engines, and argument rewriting. yoagent ships the hook, not a policy: with no middleware installed, every call runs.
#![allow(unused)] fn main() { use yoagent::{ToolCallRequest, ToolDecision, ToolMiddleware}; struct ReadOnlyPolicy; #[async_trait::async_trait] impl ToolMiddleware for ReadOnlyPolicy { async fn before_tool(&self, call: &ToolCallRequest<'_>) -> ToolDecision { match call.tool_name { "write_file" | "edit_file" | "bash" => { ToolDecision::Deny("read-only session".into()) } _ => ToolDecision::Allow, } } } let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Sonnet 5")) .with_tools(default_tools()) .with_tool_middleware(ReadOnlyPolicy); }
Semantics:
Allow— the call proceeds (with the current arguments).Modify(args)— the call proceeds with replacement arguments (e.g. rewrite a path into a sandbox). Later middleware in the chain see the rewritten arguments; theToolExecutionStartevent carries what actually runs.Deny(reason)— the call never executes. The reason is returned to the LLM as an error tool result ("Tool call denied: ..."), so the model can adapt — pick another tool, ask the user — and the loop continues. A denial never aborts the run.
A middleware that panics is contained: the call is denied (reason "tool middleware panicked") and the loop continues — a buggy policy can't kill the
run.
The hook is async, so an interactive app can prompt a human before deciding.
Note that under the default Parallel execution strategy, middleware for
parallel tool calls run concurrently — if you need one-at-a-time approval UX,
serialize inside your middleware (e.g. a tokio::sync::Mutex) or switch to
ToolExecutionStrategy::Sequential.
Sub-agents gate their own tool calls the same way via
SubAgentTool::with_tool_middleware.
Structured Outputs
Get a typed, schema-validated reply instead of free text. The JSON Schema is enforced natively by the provider — not by prompt begging.
#![allow(unused)] fn main() { use yoagent::{Agent, provider::ModelConfig}; #[derive(serde::Deserialize)] struct Invoice { vendor: String, total_cents: u64, line_items: Vec<String>, } let mut agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Sonnet 5")); let invoice: Invoice = agent .prompt_structured( "Extract the invoice from the attached text: ...", serde_json::json!({ "type": "object", "properties": { "vendor": {"type": "string"}, "total_cents": {"type": "integer"}, "line_items": {"type": "array", "items": {"type": "string"}} }, "required": ["vendor", "total_cents", "line_items"] }), ) .await?; }
Derive the schema however you like — by hand as above, or with the
schemars crate (convert with
serde_json::to_value(schemars::schema_for!(Invoice))). Mind the provider
dialects: OpenAI strict mode requires additionalProperties: false and every
property listed in required; Gemini rejects $defs/$ref. Schemas are
passed through as given.
How each provider enforces it
| Protocol | Mechanism |
|---|---|
| Anthropic | Forced tool call — a synthetic tool is built from your schema and tool_choice forces it; the loop unwraps the call back into text |
| OpenAI-compatible | response_format: {type: "json_schema", strict: true} |
| Google Gemini | generationConfig.responseSchema + JSON mime type (note: Gemini uses an OpenAPI-style schema dialect — your schema is passed through as given) |
| OpenAI Responses / Azure / Vertex / Bedrock | Not yet wired — a warning is logged and the model replies as free text, which still must parse into T |
Semantics & caveats
prompt_structuredruns the loop to completion internally and returns the parsedT— there is no event receiver for this call.- Three error shapes:
Provider { message }when the API call itself failed (auth, network, a schema-induced 400 — retrying the parse is pointless);Parse { source, raw }when the model's text didn't deserialize (the raw text is preserved so you can retry or salvage);NoOutputwhen the run produced no text. Only messages produced by this call are considered — stale output from earlier turns is never parsed. - On Anthropic the forced tool call preempts regular tools for that request, and disables extended thinking for that request (forced tool choice and thinking are mutually exclusive at the API level — a warning is logged). Treat structured prompts as extraction/finalization calls, not agentic tool-using turns.
- Markdown code fences around the JSON are stripped defensively before parsing.
Context Management
Long-running agents accumulate messages that exceed the model's context window. yoagent provides token tracking, overflow detection, tiered compaction, and execution limits.
Token Estimation
Fast estimation without external tokenizer dependencies:
#![allow(unused)] fn main() { use yoagent::context::{estimate_tokens, message_tokens, total_tokens}; estimate_tokens("Hello world"); // ~3 tokens (chars / 4) message_tokens(&agent_message); // estimate for a single message total_tokens(&messages); // estimate for all messages }
Context Tracking
ContextTracker combines real token counts from provider responses with estimation for new messages — more accurate than pure estimation:
#![allow(unused)] fn main() { use yoagent::context::ContextTracker; let mut tracker = ContextTracker::new(); // After each assistant response, record the real usage: tracker.record_usage(&assistant_usage, message_index); // Get current context size (real usage + estimated trailing): let tokens = tracker.estimate_context_tokens(agent.messages()); // After compaction, reset the tracker: tracker.reset(); }
When no usage data is available, it falls back to chars/4 estimation.
Context Overflow Detection
When the context exceeds a model's window, providers return overflow errors. yoagent detects these automatically across all major providers.
HTTP-level detection
Providers that check before streaming (Google, Bedrock, Vertex) return ProviderError::ContextOverflow:
#![allow(unused)] fn main() { use yoagent::provider::ProviderError; match agent.prompt("...").await { // The loop already handles this — but you can also match it: Err(ProviderError::ContextOverflow { message }) => { // Compact and retry } _ => {} } }
ProviderError::classify() auto-detects overflow from error messages covering Anthropic, OpenAI, Google, AWS Bedrock, xAI, Groq, OpenRouter, llama.cpp, LM Studio, MiniMax, Kimi, GitHub Copilot, and generic patterns.
Message-level detection
SSE-based providers (Anthropic, OpenAI) return overflow as a StopReason::Error message. Check with:
#![allow(unused)] fn main() { if message.is_context_overflow() { // Compact and retry } }
Handling overflow in your application
yoagent provides the detection and building blocks. Your application wires the compaction strategy:
#![allow(unused)] fn main() { // Proactive: check before each prompt let tokens = tracker.estimate_context_tokens(agent.messages()); if tokens > context_window - reserve { let compacted = compact_messages(agent.messages().to_vec(), &config); agent.replace_messages(compacted); } // Reactive: catch overflow errors // ... on ContextOverflow or message.is_context_overflow(): // compact, then retry with agent.continue_loop() }
For LLM-based summarization (asking the model to summarize old messages), implement that in your application layer — yoagent provides replace_messages() and compact_messages() as building blocks.
ContextConfig
#![allow(unused)] fn main() { pub struct ContextConfig { pub max_context_tokens: usize, // Default: 100,000 pub system_prompt_tokens: usize, // Default: 4,000 pub keep_recent: usize, // Default: 10 pub keep_first: usize, // Default: 2 pub tool_output_max_lines: usize, // Default: 50 } }
Auto-Derivation from ModelConfig
When you set a ModelConfig but don't explicitly set a ContextConfig, the compaction budget is automatically derived from the model's context_window — reserving 80% for context and 20% for output:
#![allow(unused)] fn main() { // MiniMax with 1M context → compacts at 800K (no manual config needed) let agent = Agent::from_config(ModelConfig::minimax("MiniMax-Text-01", "MiniMax Text 01")); // Anthropic with 200K context → compacts at 160K let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")); }
The priority chain:
- Explicit
with_context_config(...)→ always wins - Has
model_config→ auto-derives fromcontext_window(80%) - Neither →
ContextConfig::default()(100K)
You can also derive manually:
#![allow(unused)] fn main() { let config = ContextConfig::from_context_window(1_000_000); // config.max_context_tokens == 800_000 }
Tiered Compaction
compact_messages() tries each level in order, stopping as soon as messages fit the budget:
Level 1: Truncate Tool Outputs
Replaces long tool outputs with head + tail (keeping first N/2 and last N/2 lines). This is the cheapest — preserves conversation structure, typically saves 50-70% in coding sessions.
Level 2: Summarize Old Turns
Keeps the last keep_recent messages in full detail. Older assistant messages are replaced with one-line summaries like "[Summary] [Assistant used 3 tool(s)]", and their tool results are dropped.
Level 3: Drop Middle Messages
Keeps keep_first messages from the start and keep_recent from the end, dropping everything in between. A marker message notes how many were removed.
ExecutionLimits
Prevents runaway agents:
#![allow(unused)] fn main() { pub struct ExecutionLimits { pub max_turns: usize, // Default: 50 pub max_total_tokens: usize, // Default: 1,000,000 pub max_duration: Duration, // Default: 600s (10 min) } }
When a limit is reached, the agent stops with a message like "[Agent stopped: Max turns reached (50/50)]".
Disabling Context Management
#![allow(unused)] fn main() { let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .without_context_management(); }
This sets both context_config and execution_limits to None.
Prompt Caching
yoagent automatically optimizes API costs through prompt caching. For providers that support it, stable content (system prompts, tool definitions, conversation history) is cached between turns, giving you up to 90% savings on input tokens.
How It Works
In a multi-turn agent loop, each request sends the full context: system prompt + tools + conversation history. Without caching, you pay full price for all of it every turn. With caching, the provider reuses previously processed prefixes.
Provider Support
| Provider | Caching Type | Savings | Framework Action |
|---|---|---|---|
| Anthropic | Explicit (cache breakpoints) | 90% on hits | ✅ Auto-placed |
| OpenAI | Automatic (>1024 tokens) | 50% on hits | None needed |
| DeepSeek | Automatic prefix cache | Varies by model | None needed |
| Google Gemini | Implicit (automatic) | Varies | None needed |
| Azure OpenAI | Automatic (same as OpenAI) | 50% on hits | None needed |
| Amazon Bedrock | None (no automatic caching) | — | Not supported |
What Gets Cached (Anthropic)
yoagent places up to 3 cache breakpoints automatically:
- System prompt — stable across all turns
- Tool definitions — rarely change between turns
- Conversation history — second-to-last message, so the growing prefix is cached
This means on a typical multi-turn conversation, only the latest user message and the new assistant response cost full price.
DeepSeek
DeepSeek's API manages context caching automatically. yoagent does not send
Anthropic-style cache_control markers for DeepSeek; instead, keep stable
prefixes stable (system prompt, tool definitions, and earlier messages) and
monitor DeepSeek's prompt_cache_hit_tokens / prompt_cache_miss_tokens
usage fields through Usage.cache_read and Usage.input.
Configuration
Caching is enabled by default with automatic breakpoint placement. No configuration needed for optimal behavior.
Disable Explicit Cache Hints
#![allow(unused)] fn main() { use yoagent::{CacheConfig, CacheStrategy}; let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_cache_config(CacheConfig { enabled: false, ..Default::default() }); }
This disables yoagent-managed cache hints for providers such as Anthropic. It does not turn off automatic server-side caching for providers such as DeepSeek or OpenAI.
Fine-Grained Control
#![allow(unused)] fn main() { let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_cache_config(CacheConfig { enabled: true, strategy: CacheStrategy::Manual { cache_system: true, cache_tools: true, cache_messages: false, // Don't cache conversation history }, }); }
Monitoring Cache Usage
Every Usage struct includes cache statistics:
#![allow(unused)] fn main() { // After a response: let usage = message.usage(); // from assistant message println!("Cache read: {} tokens", usage.cache_read); println!("Cache write: {} tokens", usage.cache_write); println!("Cache hit rate: {:.1}%", usage.cache_hit_rate() * 100.0); }
cache_read— tokens served from cache (cheap)cache_write— tokens written to cache when the provider reports that metriccache_hit_rate()— fraction of input tokens from cache (0.0–1.0)
Cost Impact
For a typical 10-turn agent conversation with Anthropic Claude:
| Without Caching | With Caching (auto) |
|---|---|
| ~500K input tokens billed at full price | ~50K at full price + ~450K at 10% price |
| $1.50 (Claude Sonnet 5) | $0.29 (Claude Sonnet 5) |
That's an ~80% cost reduction with zero configuration.
Best Practices
- Keep system prompts stable — changing the system prompt between turns invalidates the cache
- Don't shuffle tools — tool order matters for cache prefix matching
- Let it work automatically — the default
CacheStrategy::Autois optimal for most use cases - Monitor
cache_hit_rate()— if it's consistently low, check if your system prompt or tools are changing unexpectedly
Retry with Backoff
When an LLM provider returns a transient error — rate limit (HTTP 429) or network failure — yoagent automatically retries with exponential backoff and jitter. No configuration required; it works out of the box.
How it works
Request → Error? → Retryable? → Wait (backoff + jitter) → Retry → ...
↓ No
Fail immediately
- The agent loop calls the provider
- If the provider returns a retryable error:
- If a
retry-afterdelay was provided (rate limits), use that - Otherwise, calculate delay:
initial_delay × multiplier^(attempt-1)with ±20% jitter - Wait, then retry
- If a
- After
max_retriesattempts, the error propagates normally
What gets retried
| Error Type | Retried? | Why |
|---|---|---|
RateLimited (429) | ✅ Yes | Temporary — provider will accept requests again soon |
Network | ✅ Yes | Transient — connection resets, timeouts, DNS failures |
Auth (401/403) | ❌ No | Permanent — wrong API key won't fix itself |
Api (400, etc.) | ❌ No | Permanent — bad request won't change on retry |
Cancelled | ❌ No | User-initiated — respect the cancellation |
Default configuration
#![allow(unused)] fn main() { RetryConfig { max_retries: 3, // Up to 3 retry attempts initial_delay_ms: 1000, // 1 second before first retry backoff_multiplier: 2.0, // Double the delay each attempt max_delay_ms: 30_000, // Cap at 30 seconds } }
With defaults, the retry delays are approximately:
- Attempt 1: ~1s
- Attempt 2: ~2s
- Attempt 3: ~4s
(±20% jitter to avoid thundering herd when multiple agents hit the same provider)
Configuration
Using the Agent builder
#![allow(unused)] fn main() { use yoagent::agent::Agent; use yoagent::retry::RetryConfig; // Default — 3 retries, exponential backoff (recommended) let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")); // Custom — more retries, longer initial delay let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_retry_config(RetryConfig { max_retries: 5, initial_delay_ms: 2000, backoff_multiplier: 2.0, max_delay_ms: 60_000, }); // Disable retries entirely let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_retry_config(RetryConfig::none()); }
Using AgentLoopConfig directly
#![allow(unused)] fn main() { use yoagent::agent_loop::AgentLoopConfig; use yoagent::retry::RetryConfig; let config = AgentLoopConfig { // ...other fields... retry_config: RetryConfig { max_retries: 3, initial_delay_ms: 1000, backoff_multiplier: 2.0, max_delay_ms: 30_000, }, }; }
Rate limit headers
When a provider returns ProviderError::RateLimited { retry_after_ms: Some(5000) }, yoagent uses that exact delay instead of the calculated backoff. This respects the provider's guidance — if Anthropic says "retry after 5 seconds", we wait 5 seconds, not our own estimate.
If no retry_after_ms is provided, the exponential backoff kicks in.
Observability
Retry attempts are logged via tracing at the WARN level:
WARN Provider error (attempt 1/3), retrying in 1.1s: Rate limited, retry after 1000ms
WARN Provider error (attempt 2/3), retrying in 2.3s: Rate limited, retry after 2000ms
Subscribe to tracing events in your application to surface these in your UI:
#![allow(unused)] fn main() { use tracing_subscriber; // Simple stderr logging tracing_subscriber::fmt::init(); // Or filter to just retries tracing_subscriber::fmt() .with_env_filter("yoagent::retry=warn") .init(); }
Design notes
- Retry lives in the agent loop, not inside individual providers. One config controls all retry behavior.
- Jitter prevents thundering herd: when many agents hit a rate limit simultaneously, jitter spreads their retries so they don't all retry at the same instant.
- Cancellation is respected: if the user cancels while waiting for a retry, the loop exits immediately.
- No retry on API errors: a malformed request will fail the same way every time. Retrying wastes time and tokens.
Skills
Skills extend an agent with domain expertise using the AgentSkills open standard. A skill is a directory containing a SKILL.md file with instructions the agent can load on demand.
How it works
Skills use progressive disclosure to manage context efficiently:
- Metadata (~100 tokens/skill) — name + description, always in the system prompt
- Instructions (<5k tokens) — SKILL.md body, loaded when the agent decides the skill is relevant
- Resources (unlimited) — scripts, references, assets — loaded only when needed
The agent decides when to activate a skill based on the description alone. No trigger engine needed.
Skill format
my-skill/
├── SKILL.md # Required: YAML frontmatter + instructions
├── scripts/ # Optional: executable code
├── references/ # Optional: documentation loaded on demand
└── assets/ # Optional: templates, static resources
SKILL.md uses YAML frontmatter:
---
name: git
description: Git operations — commit, branch, merge, rebase. Use when the user mentions version control.
---
# Git Skill
## Workflow
1. Run `git status` first
2. Stage changes, write conventional commit messages
3. For merges, check for conflicts first
## Scripts
For complex diffs: `bash {baseDir}/scripts/diff_summary.sh`
Loading skills
#![allow(unused)] fn main() { use yoagent::SkillSet; // Load from multiple directories (later dirs override earlier on name conflict) let skills = SkillSet::load(&["./skills", "~/.yoagent/skills"])?; // Or load from a single directory with a label let workspace_skills = SkillSet::load_dir("./skills", "workspace")?; }
Using with Agent
#![allow(unused)] fn main() { use yoagent::{Agent, SkillSet}; let skills = SkillSet::load(&["./skills"])?; let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_system_prompt("You are a coding assistant.") .with_skills(skills) // Appends skill index to system prompt .with_tools(tools); }
The agent's system prompt will include:
<available_skills>
<skill>
<name>git</name>
<description>Git operations — commit, branch, merge, rebase.</description>
<location>/path/to/skills/git/SKILL.md</location>
</skill>
</available_skills>
When the agent encounters a task matching a skill, it reads the SKILL.md using the read_file tool and follows the instructions. No special infrastructure needed.
Precedence
When loading from multiple directories, later directories take precedence. A skill in ./skills/ overrides the same-named skill in ~/.yoagent/skills/.
You can also merge skill sets explicitly:
#![allow(unused)] fn main() { let mut base = SkillSet::load_dir("/usr/share/yoagent/skills", "bundled")?; let user = SkillSet::load_dir("~/.yoagent/skills", "user")?; let workspace = SkillSet::load_dir("./skills", "workspace")?; base.merge(user); base.merge(workspace); // workspace wins on conflict }
Compatibility
By following the AgentSkills standard, skills written for yoagent work with Claude Code, Codex CLI, Gemini CLI, Cursor, OpenCode, Goose, and any other compatible agent. Write once, use everywhere.
Design philosophy
Skills are deliberately simple:
- No trigger engine — the LLM decides from descriptions
- No compile-time registration — skills use existing tools (read_file, bash)
- No plugin API — skills are just files
- No runtime loading — loaded at startup, that's it
If a skill needs a custom tool, it can provide an MCP server.
Sub-Agents
Sub-agents let a parent agent delegate tasks to child agent loops, each with their own system prompt, tools, and provider. The parent LLM invokes them like any other tool.
Overview
Parent Agent
├── prompt("Research X and implement Y")
│ ├── calls SubAgentTool("researcher", task="Research X")
│ │ └── child agent_loop() with read/search tools → returns findings
│ ├── calls SubAgentTool("coder", task="Implement Y based on findings")
│ │ └── child agent_loop() with edit/write tools → returns result
│ └── summarizes both results
Each sub-agent invocation starts a fresh conversation — no state leaks between calls.
Creating Sub-Agents
#![allow(unused)] fn main() { use std::sync::Arc; use yoagent::sub_agent::SubAgentTool; use yoagent::provider::ModelConfig; use yoagent::tools; let researcher = SubAgentTool::from_config( "researcher", ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5"), ) .with_description("Searches and reads files to gather information.") .with_system_prompt("You are a research assistant. Be thorough and concise.") .with_tools(vec![ Arc::new(tools::ReadFileTool::new()), Arc::new(tools::SearchTool::new()), ]) .with_max_turns(10); }
Registering on a Parent Agent
#![allow(unused)] fn main() { use yoagent::agent::Agent; let mut agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_system_prompt("You coordinate between sub-agents.") .with_sub_agent(researcher) .with_sub_agent(coder); }
The parent sees sub-agents as regular tools. It decides when to delegate based on its system prompt.
Parallel Execution
When the parent LLM calls multiple sub-agents in a single response, they run concurrently (default Parallel strategy). Two sub-agents each taking 50ms complete in ~50ms total, not 100ms.
Configuration
| Method | Purpose |
|---|---|
with_description() | What the parent LLM sees (helps it decide when to delegate) |
with_system_prompt() | The sub-agent's own instructions |
with_skills() | Attach a SkillSet — its index is appended to the sub-agent's system prompt (mirrors Agent::with_skills) |
from_config(name, config) / from_provider(name, provider, config) | Set the sub-agent's model, provider, and metadata from a ModelConfig — resolves the env key automatically and can use a different model than the parent |
with_api_key() | Override the env-resolved API key explicitly |
with_tools() | Tools available to the sub-agent (accepts Vec<Arc<dyn AgentTool>>) |
with_max_turns(N) | Turn limit (default: 10). Primary guard against runaway execution. |
with_thinking() | Enable extended thinking for the sub-agent |
with_cache_config() | Prompt caching settings |
with_turn_delay() | Inter-turn delay to throttle API calls (useful for rate-limit-sensitive providers) |
with_retry_config() | Custom retry configuration for transient errors |
with_tool_execution() | Tool execution strategy (Parallel, Sequential, Batched) |
Event Forwarding
When the parent provides an on_update callback (standard for all tools), sub-agent events are forwarded as ToolExecutionUpdate events. The parent's UI sees real-time progress from the child:
- Text deltas from the sub-agent's LLM responses
- Tool call notifications from the sub-agent's tool usage
Shared State
By default, each sub-agent invocation is isolated — to pass data between sub-agents, the parent must re-paste it into every prompt. For large artifacts (CI logs, codebases, analysis results), this wastes context tokens.
SharedState solves this: store an artifact once, and any number of sub-agents read/write it by reference.
#![allow(unused)] fn main() { use yoagent::shared_state::SharedState; let state = SharedState::new(); state.set("ci_log", large_log_text).await.unwrap(); let analyzer = SubAgentTool::from_provider( "analyzer", provider.clone(), ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5"), ) .with_system_prompt("Analyze the CI log for failures.") .with_shared_state(state.clone()); // opt-in }
When .with_shared_state() is used, the sub-agent automatically gets:
- A
shared_statetool withget,set,list, andremoveactions - A system prompt appendix listing available keys and their sizes
The sub-agent reads the artifact via tool call instead of having it pasted into the prompt:
Sub-agent calls: shared_state(action="get", key="ci_log")
Sub-agent calls: shared_state(action="set", key="summary", value="...")
The parent reads results back programmatically:
#![allow(unused)] fn main() { let summary = state.get("summary").await.expect("sub-agent wrote this"); }
Parallel Sub-Agents with Shared State
Multiple sub-agents can share the same SharedState concurrently. Each gets its own clone of the Arc handle — reads are concurrent, writes are serialized by tokio::sync::RwLock.
#![allow(unused)] fn main() { let error_analyst = SubAgentTool::from_provider( "error_analyst", provider.clone(), ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5"), ) .with_shared_state(state.clone()); let perf_analyst = SubAgentTool::from_provider( "perf_analyst", provider.clone(), ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5"), ) .with_shared_state(state.clone()); // Both run in parallel, reading the same artifact and writing different keys }
Backends
SharedState is backed by a pluggable SharedStateBackend trait. Two built-in backends are provided:
MemoryBackend (default) — in-memory HashMap with a byte capacity limit:
#![allow(unused)] fn main() { let state = SharedState::new(); // 10MB default let state = SharedState::with_max_bytes(50 * 1024 * 1024); // 50MB }
A set call that would exceed capacity returns Err(CapacityError).
FileBackend — one file per key, persistent across process restarts:
#![allow(unused)] fn main() { use yoagent::shared_state::FileBackend; let state = SharedState::with_backend(FileBackend::new(".agent-state")); }
Keys are percent-encoded to filenames (reversible, no collisions). Useful for debugging (inspect state with ls / cat) and for long-running workflows where memory limits matter.
Custom backends implement the SharedStateBackend trait:
#![allow(unused)] fn main() { use yoagent::shared_state::{SharedStateBackend, SharedStateError}; #[async_trait::async_trait] impl SharedStateBackend for MyRedisBackend { async fn get(&self, key: &str) -> Result<Option<String>, SharedStateError> { ... } async fn set(&self, key: &str, value: String) -> Result<(), SharedStateError> { ... } async fn remove(&self, key: &str) -> Result<bool, SharedStateError> { ... } async fn keys(&self) -> Result<Vec<String>, SharedStateError> { ... } async fn summary(&self) -> Result<String, SharedStateError> { ... } } let state = SharedState::with_backend(MyRedisBackend::new()); }
See examples/shared_state.rs for a complete parallel analysis demo.
Multi-Provider Support
Sub-agents can use any provider supported by yoagent — not just Anthropic. Pass a ModelConfig to configure the base URL, compat flags, and other provider-specific settings:
#![allow(unused)] fn main() { use yoagent::provider::ModelConfig; let model_config = ModelConfig::xai("grok-4-1-fast-reasoning", "Grok 3 Mini Fast"); // `from_config` resolves OpenAiCompatProvider from the config's protocol and // the key from XAI_API_KEY. let analyst = SubAgentTool::from_config("analyst", model_config) .with_tools(vec![...]); }
This works with all providers: OpenAI, Groq, DeepSeek, Gemini, Mistral, xAI, and more. See Model Presets for the full list of first-class factory methods.
Design Decisions
- Context isolation: Each invocation starts fresh. Sub-agents don't accumulate history across calls.
- Nesting supported: Sub-agents can be given other
SubAgentTools for recursive delegation (seeexamples/rlm.rs). Usewith_max_turns()to prevent infinite chains. - Cancellation propagation: The parent's cancellation token is forwarded. Aborting the parent aborts all sub-agents.
- Turn limiting: The default 10-turn limit prevents runaway execution. The parent's execution limits also apply to total wall-clock time.
Examples
examples/sub_agent.rs— Coordinator with researcher and coder sub-agentsexamples/code_review.rs— 3 parallel sub-agents reviewing a file via shared stateexamples/rlm.rs— Recursive Language Model: nested sub-agents with autonomous file discovery
State Persistence
yoagent supports saving and restoring agent conversation state, enabling pause/resume workflows, state transfer between processes, and conversation checkpointing.
Save and Restore
#![allow(unused)] fn main() { use yoagent::agent::Agent; // After running some conversation turns... let json = agent.save_messages()?; std::fs::write("conversation.json", &json)?; // Later, in a new process: let json = std::fs::read_to_string("conversation.json")?; let mut agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_system_prompt("You are helpful."); agent.restore_messages(&json)?; // Continue the conversation — the agent sees the full history let rx = agent.prompt("Follow up question").await; }
Builder Initialization
For constructing an agent with pre-existing history:
#![allow(unused)] fn main() { let saved: Vec<AgentMessage> = serde_json::from_str(&json)?; let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_messages(saved) .with_system_prompt("..."); }
JSON Format
Messages serialize as a JSON array. Each message is tagged by role:
[
{
"role": "user",
"content": [{"type": "text", "text": "Hello"}],
"timestamp": 1700000000000
},
{
"role": "assistant",
"content": [{"type": "text", "text": "Hi there!"}],
"stopReason": "stop",
"model": "claude-sonnet-5",
"provider": "anthropic",
"usage": {"input": 100, "output": 50, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 150},
"timestamp": 1700000001000
}
]
As of 0.13 all field names are camelCase; the pre-0.13 snake_case names
(cache_read, cache_write, total_tokens, error_message,
provider_metadata) are still accepted when loading older files.
Extension messages use a nested structure:
{
"role": "extension",
"kind": "status_update",
"data": {"status": "running"}
}
Context Tracking
ContextTracker and ExecutionTracker are runtime-only and not persisted. This is by design — both are created fresh each agent_loop() invocation and operate on whatever messages are in context at that point. Restoring messages and calling prompt() works correctly without any special recalculation.
What's Serializable
| Type | Serialize | Deserialize | PartialEq |
|---|---|---|---|
Content | Yes | Yes | Yes |
Message | Yes | Yes | Yes |
AgentMessage | Yes | Yes | Yes |
ExtensionMessage | Yes | Yes | Yes |
Usage | Yes | Yes | Yes |
StopReason | Yes | Yes | Yes |
ToolResult | Yes | Yes | Yes |
CacheConfig | Yes | Yes | Yes |
ToolExecutionStrategy | Yes | Yes | Yes |
ContextConfig | Yes | Yes | No |
ExecutionLimits | Yes | Yes | No |
For branching history — fork, checkpoints, edit-and-rerun — see Session Trees.
Session Trees
A Session stores conversation history as a tree, not a flat list. Every
entry has an id and parent_id; the head points at the current branch
tip. Appending after a seek creates a new branch — history is never
overwritten. This is the primitive behind:
- Fork — try a different direction from any point
- Checkpoints — label a state, come back to it later
- Edit & re-run — change an earlier turn on a new branch; the original branch stays intact
#![allow(unused)] fn main() { use yoagent::{Agent, Session, provider::ModelConfig}; let mut session = Session::new(); // Run a turn, record it. let mut agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Sonnet 5")); let mut rx = agent.prompt("draft a plan").await; while rx.recv().await.is_some() {} agent.finish().await; session.append_new(agent.messages())?; session.checkpoint("first-draft")?; // ... more turns ... // Rewind to the checkpoint and branch. session.seek_checkpoint("first-draft")?; let mut agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Sonnet 5")) .with_messages(session.path_messages()); // only this branch's history let mut rx = agent.prompt("actually, make it a library instead").await; while rx.recv().await.is_some() {} agent.finish().await; session.append_new(agent.messages())?; // new branch recorded // Both branches exist: assert_eq!(session.branch_tips().len(), 2); }
Persistence: JSONL
#![allow(unused)] fn main() { std::fs::write("session.jsonl", session.to_jsonl())?; let restored = Session::from_jsonl(&std::fs::read_to_string("session.jsonl")?)?; }
One entry per line, append-friendly, diff-friendly. On load the head is the last line's entry, and the file is validated (duplicate ids and dangling/forward parent references are rejected — which also makes cycles impossible).
Compaction caveat:
append_newverifies the agent's history still extends the session path and returnsSessionError::HistoryDivergedwhen it doesn't. The usual cause is context compaction (on by default) rewriting the agent's messages — disable context management on session-tracked agents, or rebuild the branch withSession::from_messagesafter a divergence. The flatsave_messages()/restore_messages()API remains for single-branch persistence.
API sketch
| Method | Purpose |
|---|---|
append(msg) -> id | Add as child of head, advance head |
append_new(&agent_messages) | Record everything beyond the current path (verifies the prefix; errors on divergence) |
seek(id) / seek_checkpoint(label) | Move the head (fork point) |
checkpoint(label) | Label the head |
path_messages() | Root→head messages — feed to Agent::with_messages |
branch_tips() / children(id) / entries() | Inspect the tree |
to_jsonl() / from_jsonl() | Persist / restore |
GASP
This tree is a natural format for the
GASP transcripts/ tier — the
raw-conversation cold tier (the spec leaves its format open). The semantic event log
lives in the gasp feature: a recorder over the AgentEvent
stream that emits a conformance-checked agent repo.
GASP: Your Agent Is a Git Repo
GASP — the Git Agent State Protocol —
keeps an agent's durable self in a git repository: an append-only semantic
event log (state/events.jsonl) that folds into a typed
goal/run/model/tool graph, alongside identity, skills, and memory tiers.
Restore = git clone + replay. Clone your agent onto a new machine and it
remembers everything, with lineage.
yoagent bridges to GASP through the gasp feature (backed by
yoagent-state, the reference
runtime). The bridge is a consumer of the AgentEvent stream — zero
agent-loop changes:
yoagent = { version = "0.12", features = ["gasp"] }
#![allow(unused)] fn main() { use yoagent::gasp::{GaspRecorder, GoalRef}; let recorder = GaspRecorder::init( "./my-agent-repo", "my-agent", "worker-1", GoalRef::New { title: "ship the feature".into() }, ).await?; let (tx, record_handle) = recorder.recording_sender("implement the parser", None); agent.prompt_with_sender("implement the parser", tx).await; let run_id = record_handle.await??; // events appended + committed }
What gets recorded
| Agent activity | GASP events |
|---|---|
| loop starts | run.started (the goal is stamped in the run commit's Goal: trailer) |
| each assistant turn | model.called / model.finished paired nodes |
| each tool execution | tool.called / tool.finished (with success flag) |
| loop ends | run.finished with the outcome (completed / error / aborted / ...) |
The semantic log stores bounded one-line summaries — the task string
(verbatim), model ids, and the first 200 characters of tool inputs, tool
outputs, and assistant text — never full transcripts. If secrets can flow
through tool arguments or outputs, install a redacting summarizer
(recorder.with_summarizer(...)) before recording: the repo is designed to
be cloned and shared, and committed history is hard to scrub. Full
transcripts belong in GASP's cold transcripts/ tier —
Session::to_jsonl is a natural format for it.
Robustness: a crashed process leaves no open run — the recorder closes stale
runs as interrupted on open, and a dropped event stream finishes the run
with the outcome derived so far. One git commit per run keeps the history
append-only, and the init scaffolding (manifest, identity) is committed so a
fresh clone is a complete, conformant agent. If recording fails mid-run
(disk, lease, git), recording stops and the error surfaces via the returned
handle — always await it — while event forwarding to your UI continues
uninterrupted. GASP repos are single-writer: don't share one repo between
live workers, and record one run at a time.
Tested conformance
yoagent's CI emits an agent repo with a mock provider and runs the GASP conformance checker against it — all seven mechanical checks (envelope round-trip, replay, vocabulary, append-only git history, causation integrity, restore, domain↔ops consistency) must pass on every commit. Try it yourself:
cargo run --example gasp_emit --features gasp -- /tmp/my-agent
git clone https://github.com/yologdev/gasp && cd gasp/conformance-check
cargo run -q -- /tmp/my-agent
# conformant: all checks passed
Lifecycle Callbacks
yoagent provides three lifecycle callbacks that let you observe and control the agent loop without modifying its internals.
Callbacks
before_turn
Called before each LLM call. Receives the current message history and the turn number (0-indexed). Return false to abort the loop.
#![allow(unused)] fn main() { let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .on_before_turn(|messages, turn| { println!("Turn {} starting with {} messages", turn, messages.len()); turn < 10 // Stop after 10 turns }); }
after_turn
Called after each LLM response and tool execution. Receives the updated message history and the turn's token usage.
#![allow(unused)] fn main() { use std::sync::{Arc, Mutex}; let total_cost = Arc::new(Mutex::new(0u64)); let cost_tracker = total_cost.clone(); let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .on_after_turn(move |_messages, usage| { let mut cost = cost_tracker.lock().unwrap(); *cost += usage.input + usage.output; println!("Cumulative tokens: {}", *cost); }); }
on_error
Called when the LLM returns a StopReason::Error. Receives the error message string.
#![allow(unused)] fn main() { let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .on_error(|err| { eprintln!("LLM error: {}", err); // Log to monitoring, send alert, etc. }); }
Combining Callbacks
All callbacks are optional and independent:
#![allow(unused)] fn main() { let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .on_before_turn(|_msgs, turn| turn < 20) .on_after_turn(|msgs, usage| { println!("Messages: {}, Tokens: {}/{}", msgs.len(), usage.input, usage.output); }) .on_error(|err| eprintln!("Error: {}", err)); }
Using with AgentLoopConfig
For direct loop usage without the Agent wrapper:
#![allow(unused)] fn main() { use std::sync::Arc; use yoagent::agent_loop::AgentLoopConfig; let config = AgentLoopConfig { before_turn: Some(Arc::new(|_msgs, turn| turn < 5)), after_turn: Some(Arc::new(|_msgs, _usage| { /* log */ })), on_error: Some(Arc::new(|err| eprintln!("{}", err))), // ... other fields }; }
Callback Timing
Loop iteration:
1. Inject pending messages (steering/follow-up)
2. Check execution limits
3. before_turn(messages, turn_number) <-- return false to abort
4. Compact context
5. Stream LLM response
6. Check for error/abort → on_error(message) if StopReason::Error
→ after_turn(messages, usage) even on error/abort
7. Execute tool calls
8. Track turn
9. after_turn(messages, usage)
10. Emit TurnEnd event
Telemetry
yoagent instruments the loop with tracing spans
— structured, timed, nested units your observability stack can consume. With
no subscriber installed the overhead is near-zero (a cached per-callsite
interest check); nothing is exported unless you opt in.
The span tree
agent_loop (model)
└─ llm_stream (turn, model, tokens_in, tokens_out, tokens_cached, cost_usd, error)
└─ tool (tool, tool_call_id, is_error)
llm_stream— one per turn, wrapping the provider call. Token counts are recorded from real usage;cost_usdis recorded when theModelConfighas pricing configured (CostConfig).tool— one per tool execution, with the tool name and error status; duration comes free with the span.
Local: print spans
#![allow(unused)] fn main() { tracing_subscriber::fmt() .with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE) .init(); }
Run cargo run --example telemetry to see it.
Production: OpenTelemetry
The OTel bridge is application-side — yoagent needs no OTel dependency
(that's the point of tracing). Install the
tracing-opentelemetry layer and the
same spans flow to any OTLP backend — Datadog, Grafana Tempo, Honeycomb,
Jaeger:
#![allow(unused)] fn main() { // opentelemetry_otlp 0.27+, opentelemetry_sdk 0.27+, tracing-opentelemetry 0.28+ use opentelemetry::trace::TracerProvider as _; use tracing_subscriber::layer::SubscriberExt; let exporter = opentelemetry_otlp::SpanExporter::builder() .with_tonic() .build()?; let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder() .with_batch_exporter(exporter) .build(); let subscriber = tracing_subscriber::registry() .with(tracing_opentelemetry::layer().with_tracer(provider.tracer("yoagent-app"))); tracing::subscriber::set_global_default(subscriber)?; }
(The OTel crates rework their builder APIs between releases — if this snippet
drifts, the authoritative wiring is the
tracing-opentelemetry docs; yoagent
only emits standard tracing spans and does not depend on OTel.)
Because these are ordinary tracing spans, an agent call nests inside your
app's existing request traces (e.g. an axum handler span) automatically.
What it buys you
- Cost attribution — dollars per turn/model in your dashboards, from the
same
CostConfigdata assession_cost_usd. - Latency diagnosis — "40s request: 8s provider, 30s in one bash tool."
- Audit — which tools ran, with what outcome, per session.
MCP Integration
What is MCP?
The Model Context Protocol (MCP) is a JSON-RPC 2.0 protocol that lets AI agents discover and call tools from external servers. It defines a standard way for agents to connect to tool providers over two transports:
- Stdio — spawn a child process, communicate via stdin/stdout (newline-delimited JSON)
- HTTP — POST JSON-RPC requests to an HTTP endpoint
Connecting to MCP Servers
Stdio Transport
Use with_mcp_server_stdio() to spawn an MCP server process and register its tools:
use yoagent::Agent; use yoagent::provider::ModelConfig; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let mut agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_system_prompt("You are a helpful assistant with file access.") .with_mcp_server_stdio( "npx", &["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], None, ) .await?; let rx = agent.prompt("List files in /tmp").await; // handle events... Ok(()) }
You can pass environment variables to the server process:
#![allow(unused)] fn main() { use std::collections::HashMap; let mut env = HashMap::new(); env.insert("API_TOKEN".into(), "secret".into()); let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_mcp_server_stdio("my-mcp-server", &["--port", "0"], Some(env)) .await?; }
HTTP Transport
For remote MCP servers exposed over HTTP:
#![allow(unused)] fn main() { let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_mcp_server_http("http://localhost:8080/mcp") .await?; }
How MCP Tools Work
When you call with_mcp_server_stdio() or with_mcp_server_http(), yoagent:
- Connects to the MCP server and performs the
initializehandshake - Calls
tools/listto discover available tools - Wraps each MCP tool as an
AgentToolviaMcpToolAdapter - Adds them to the agent's tool list
MCP tools appear alongside built-in tools. The LLM sees them with their original names, descriptions, and JSON Schema parameters — it can call them just like any other tool.
Mixing Built-in and MCP Tools
#![allow(unused)] fn main() { use yoagent::tools::default_tools; let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_tools(default_tools()) // bash, read, write, edit, list, search .with_mcp_server_stdio("my-db-server", &[], None) .await?; // Agent now has both built-in coding tools AND MCP database tools }
Using the MCP Client Directly
For lower-level control, use McpClient directly:
#![allow(unused)] fn main() { use yoagent::mcp::{McpClient, McpToolAdapter}; use std::sync::Arc; use tokio::sync::Mutex; let client = McpClient::connect_stdio("my-server", &[], None).await?; let tools = client.list_tools().await?; for tool in &tools { println!("{}: {}", tool.name, tool.description.as_deref().unwrap_or("")); } // Call a tool directly let result = client.call_tool("read_file", serde_json::json!({"path": "/tmp/test.txt"})).await?; // Or wrap as AgentTool adapters let client = Arc::new(Mutex::new(client)); let adapters = McpToolAdapter::from_client(client).await?; }
Error Handling
MCP operations return McpError:
McpError::Transport— connection or I/O failureMcpError::Protocol— unexpected response formatMcpError::JsonRpc— server returned a JSON-RPC errorMcpError::ConnectionClosed— server process exited
When an MCP tool returns isError: true, the adapter converts it to a ToolError::Failed, which the agent loop sends back to the LLM with is_error: true so it can self-correct.
OpenAPI Tool Adapter
Auto-generate AgentTool implementations from OpenAPI 3.0 specs. Point an agent at any API spec and it instantly gets callable tools for every operation.
Feature-gated — add
features = ["openapi"]to yourCargo.toml.
Quick Start
use yoagent::Agent; use yoagent::openapi::{OpenApiToolAdapter, OpenApiConfig, OperationFilter}; use yoagent::provider::ModelConfig; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let config = OpenApiConfig::new() .with_bearer_token("sk-..."); let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_system_prompt("You are an API assistant.") .with_openapi_file("petstore.yaml", config, &OperationFilter::All) .await?; Ok(()) }
Loading Specs
Three ways to load an OpenAPI spec:
#![allow(unused)] fn main() { // From a file let agent = agent.with_openapi_file("spec.yaml", config, &filter).await?; // From a URL let agent = agent.with_openapi_url("https://api.example.com/openapi.json", config, &filter).await?; // From a string (sync) let agent = agent.with_openapi_spec(&spec_string, config, &filter)?; }
Or create adapters directly for more control:
#![allow(unused)] fn main() { let adapters = OpenApiToolAdapter::from_str(&spec, config, &OperationFilter::All)?; let tools: Vec<Box<dyn AgentTool>> = adapters.into_iter().map(|a| Box::new(a) as _).collect(); }
Configuration
OpenApiConfig controls auth, headers, timeouts, and response limits:
#![allow(unused)] fn main() { let config = OpenApiConfig::new() .with_base_url("https://api.staging.example.com") // Override spec's servers .with_bearer_token("sk-...") // Bearer auth .with_header("X-Custom", "value") // Extra headers .with_timeout_secs(60) // Request timeout .with_max_response_bytes(128 * 1024) // Truncate large responses .with_name_prefix("github"); // Tool names: github__listRepos }
Authentication
#![allow(unused)] fn main() { // Bearer token let config = OpenApiConfig::new().with_bearer_token("token"); // API key in a custom header let config = OpenApiConfig::new().with_api_key("X-API-Key", "key-value"); // No auth let config = OpenApiConfig::new(); // default }
Filtering Operations
Most API specs have dozens or hundreds of operations. Use OperationFilter to select which ones become tools:
#![allow(unused)] fn main() { // All operations (default) let filter = OperationFilter::All; // Specific operations by ID let filter = OperationFilter::ByOperationId(vec![ "listRepos".into(), "getRepo".into(), "createIssue".into(), ]); // All operations with a specific tag let filter = OperationFilter::ByTag(vec!["repos".into()]); // All operations under a path prefix let filter = OperationFilter::ByPathPrefix("/repos".into()); }
How It Works
Each OpenAPI operation becomes one AgentTool:
| AgentTool method | Mapped from |
|---|---|
name() | operationId (with optional prefix) |
label() | summary or operationId |
description() | description or summary |
parameters_schema() | Combined JSON Schema from path/query/header params + request body |
When the LLM calls a tool, the adapter:
- Substitutes path parameters in the URL (
/pets/{petId}→/pets/123) - Adds query parameters as
?key=value - Adds header parameters
- Applies auth from config
- Sends the request body as JSON (if the operation has one)
- Returns the response text (with status code) to the LLM
Non-2xx responses are not treated as errors — they're returned as text so the LLM can reason about them and retry or adjust.
Mixing with Other Tools
OpenAPI tools work alongside built-in tools and MCP tools:
#![allow(unused)] fn main() { use yoagent::tools::default_tools; let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_tools(default_tools()) .with_openapi_file("github.yaml", github_config, &github_filter).await? .with_mcp_server_stdio("db-server", &[], None).await?; }
Limitations (v1)
- OpenAPI 3.0.x only (not 3.1.x)
- JSON request/response bodies only (no multipart/form-data)
- No OAuth2 or token refresh (pass tokens via
OpenApiConfig) - Operations without
operationIdare skipped - Path-level
$refitems are skipped
Providers Overview
yoagent supports multiple LLM providers through the StreamProvider trait and ApiProtocol dispatch.
Supported Protocols
| Protocol | Provider Struct | API Format |
|---|---|---|
AnthropicMessages | AnthropicProvider | Anthropic Messages API |
OpenAiCompletions | OpenAiCompatProvider | OpenAI Chat Completions |
OpenAiResponses | OpenAiResponsesProvider | OpenAI Responses API |
AzureOpenAiResponses | AzureOpenAiProvider | Azure OpenAI Responses |
GoogleGenerativeAi | GoogleProvider | Google Gemini API |
GoogleVertex | GoogleVertexProvider | Google Vertex AI |
BedrockConverseStream | BedrockProvider | AWS Bedrock ConverseStream |
ApiProtocol Enum
#![allow(unused)] fn main() { pub enum ApiProtocol { AnthropicMessages, OpenAiCompletions, OpenAiResponses, AzureOpenAiResponses, GoogleGenerativeAi, GoogleVertex, BedrockConverseStream, } }
ModelConfig
Full configuration for a model, including provider routing:
#![allow(unused)] fn main() { pub struct ModelConfig { pub id: String, // e.g. "gpt-5.5" pub name: String, // e.g. "GPT-5.5" pub api: ApiProtocol, // Which provider to use pub provider: String, // e.g. "openai" pub base_url: String, // API endpoint pub reasoning: bool, // Supports thinking/reasoning pub context_window: u32, // Context size in tokens pub max_tokens: u32, // Default max output pub cost: CostConfig, // Pricing per million tokens pub headers: HashMap<String, String>, // Extra headers pub compat: Option<OpenAiCompat>, // Quirk flags } }
First-class model presets are documented in Model Presets. Convenience constructors:
#![allow(unused)] fn main() { let anthropic = ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5"); let openai = ModelConfig::openai("gpt-5.5", "GPT-5.5"); let google = ModelConfig::google("gemini-2.5-flash", "Gemini 2.5 Flash"); let xai = ModelConfig::xai("grok-4-1-fast", "Grok 4.1 Fast"); let groq = ModelConfig::groq("llama-3.3-70b-versatile", "Llama 3.3 70B"); let deepseek = ModelConfig::deepseek("deepseek-v4-flash", "DeepSeek V4 Flash"); let mistral = ModelConfig::mistral("mistral-large-latest", "Mistral Large"); let minimax = ModelConfig::minimax("MiniMax-Text-01", "MiniMax Text 01"); let zai = ModelConfig::zai("glm-4.7", "GLM 4.7"); let qwen = ModelConfig::qwen("qwen3.6-plus", "Qwen 3.6 Plus"); let ollama = ModelConfig::ollama("http://localhost:11434/v1", "llama3.1:8b"); let local = ModelConfig::local("http://localhost:1234/v1", "my-model"); }
ProviderRegistry
Maps ApiProtocol → StreamProvider. The default registry includes all built-in providers:
#![allow(unused)] fn main() { let registry = ProviderRegistry::default(); // Use it to stream with any model let result = registry.stream(&model_config, stream_config, tx, cancel).await?; }
Custom registries:
#![allow(unused)] fn main() { let mut registry = ProviderRegistry::new(); registry.register(ApiProtocol::AnthropicMessages, AnthropicProvider); }
StreamProvider Trait
#![allow(unused)] fn main() { #[async_trait] pub trait StreamProvider: Send + Sync { async fn stream( &self, config: StreamConfig, tx: mpsc::UnboundedSender<StreamEvent>, cancel: CancellationToken, ) -> Result<Message, ProviderError>; } }
All providers receive a StreamConfig, emit StreamEvents through the channel, and return the final Message.
OpenAPI Tool Adapter
In addition to LLM providers, yoagent can auto-generate tools from any OpenAPI 3.0 spec. This is a tool integration (not a provider), but it complements the provider system by letting agents call external APIs.
Enable with features = ["openapi"]. See the OpenAPI Tools guide for details.
Model Presets
yoagent's first-class model presets are the ModelConfig::* constructors. A preset sets provider routing, base URL, context metadata, default output limits, and provider compatibility flags.
Use a preset when the provider is listed here. Use a custom ModelConfig when you need a compatible provider or endpoint that does not have a constructor yet.
First-Class Constructors
| Constructor | Provider | Protocol | Default Base URL | Context | Default Max Output |
|---|---|---|---|---|---|
ModelConfig::anthropic(id, name) | Anthropic | AnthropicMessages | https://api.anthropic.com/v1 | 200K | 16,000 |
ModelConfig::claude_fable_5() | Anthropic | AnthropicMessages | https://api.anthropic.com/v1 | 1M | 64,000 |
ModelConfig::claude_opus_4_8() | Anthropic | AnthropicMessages | https://api.anthropic.com/v1 | 1M | 64,000 |
ModelConfig::claude_sonnet_5() | Anthropic | AnthropicMessages | https://api.anthropic.com/v1 | 1M | 64,000 |
ModelConfig::claude_haiku_4_5() | Anthropic | AnthropicMessages | https://api.anthropic.com/v1 | 200K | 32,000 |
ModelConfig::openai(id, name) | OpenAI | OpenAiCompletions | https://api.openai.com/v1 | 128K | 4,096 |
ModelConfig::gpt_5_5() | OpenAI | OpenAiCompletions | https://api.openai.com/v1 | 1M | 64,000 |
ModelConfig::opencode_zen(model_id) | OpenCode Zen | by model family | https://opencode.ai/zen/v1 | 128K | 16,000 |
ModelConfig::opencode_go(model_id) | OpenCode Go | by model family | https://opencode.ai/zen/go/v1 | 128K | 16,000 |
ModelConfig::google(id, name) | Google Gemini | GoogleGenerativeAi | https://generativelanguage.googleapis.com | 1M | 8,192 |
ModelConfig::xai(id, name) | xAI | OpenAiCompletions | https://api.x.ai/v1 | 131,072 | 4,096 |
ModelConfig::groq(id, name) | Groq | OpenAiCompletions | https://api.groq.com/openai/v1 | 128K | 4,096 |
ModelConfig::deepseek(id, name) | DeepSeek | OpenAiCompletions | https://api.deepseek.com | 1M | 384K |
ModelConfig::mistral(id, name) | Mistral | OpenAiCompletions | https://api.mistral.ai/v1 | 128K | 4,096 |
ModelConfig::minimax(id, name) | MiniMax | OpenAiCompletions | https://api.minimaxi.chat/v1 | 1M | 4,096 |
ModelConfig::meta(id, name) | Meta (Muse Spark) — US-only preview as of 2026-07 | OpenAiCompletions | https://api.meta.ai/v1 | 1M | 131,072 |
ModelConfig::zai(id, name) | Z.ai | OpenAiCompletions | https://api.z.ai/api/paas/v4 | 128K | 4,096 |
ModelConfig::qwen(id, name) | Qwen / DashScope | OpenAiCompletions | https://dashscope-intl.aliyuncs.com/compatible-mode/v1 | 128K | 4,096 |
ModelConfig::ollama(base_url, model_id) | Ollama | OpenAiCompletions | caller provided | 128K | 4,096 |
ModelConfig::openai_compat(base_url, model_id, provider, compat) | Custom compatible server | OpenAiCompletions | caller provided | 128K | 4,096 |
ModelConfig::local(base_url, model_id) | Local compatible server | OpenAiCompletions | caller provided | 128K | 4,096 |
The constructors do not validate model IDs. They send the id you pass through to the provider, which lets you use newly released model IDs before yoagent updates its examples.
The named presets (claude_fable_5, claude_opus_4_8, claude_sonnet_5, claude_haiku_4_5, gpt_5_5) also fill in real CostConfig pricing. The OpenCode presets select the API protocol from the model id — see OpenCode Zen & Go.
OpenAI-Compatible Presets
These constructors all use OpenAiCompatProvider:
#![allow(unused)] fn main() { use yoagent::provider::ModelConfig; let agent = Agent::from_config(ModelConfig::deepseek( "deepseek-v4-flash", "DeepSeek V4 Flash", )); }
OpenAI-compatible presets also set OpenAiCompat flags for provider-specific API differences, such as max_tokens vs. max_completion_tokens, reasoning fields, tool result formatting, and streaming usage support. See OpenAI Compatible for the full quirk-flag list.
Ollama Models
Use ModelConfig::ollama for Ollama's OpenAI-compatible endpoint:
#![allow(unused)] fn main() { let llama = ModelConfig::ollama("http://localhost:11434/v1", "llama3.1:8b"); }
Ollama remains separate from ModelConfig::local(...) because some Ollama-served models need an assistant message after tool results, while other local OpenAI-compatible servers may not. The Ollama preset enables that transcript workaround; the generic local preset stays neutral.
Qwen Models
Use ModelConfig::qwen for hosted Qwen / DashScope:
#![allow(unused)] fn main() { let qwen = ModelConfig::qwen("qwen3.6-plus", "Qwen 3.6 Plus"); }
The default base URL is the international DashScope endpoint. For other regions, override base_url after construction:
#![allow(unused)] fn main() { let mut qwen = ModelConfig::qwen("qwen3.6-plus", "Qwen 3.6 Plus"); qwen.base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1".into(); }
Region endpoints:
- International/Singapore:
https://dashscope-intl.aliyuncs.com/compatible-mode/v1 - China/Beijing:
https://dashscope.aliyuncs.com/compatible-mode/v1 - US/Virginia:
https://dashscope-us.aliyuncs.com/compatible-mode/v1
For locally deployed Qwen, keep the local endpoint and opt into Qwen's model-family compat flags:
#![allow(unused)] fn main() { let qwen_local = ModelConfig::openai_compat( "http://localhost:1234/v1", "qwen3-local", "qwen", OpenAiCompat::qwen(), ); }
If a local serving layer also has its own quirks, combine the compat flags explicitly. For example, Qwen served by Ollama may need both Qwen reasoning parsing and Ollama's tool-result transcript workaround:
#![allow(unused)] fn main() { let mut compat = OpenAiCompat::qwen(); compat.requires_assistant_after_tool_result = true; let qwen_ollama = ModelConfig::openai_compat( "http://localhost:11434/v1", "qwen2.5-coder:7b", "ollama", compat, ); }
DeepSeek Models
Use the current DeepSeek API model IDs by default:
#![allow(unused)] fn main() { let flash = ModelConfig::deepseek("deepseek-v4-flash", "DeepSeek V4 Flash"); let pro = ModelConfig::deepseek("deepseek-v4-pro", "DeepSeek V4 Pro"); }
Legacy DeepSeek aliases still work because ModelConfig::deepseek passes the model ID through unchanged:
#![allow(unused)] fn main() { let chat = ModelConfig::deepseek("deepseek-chat", "DeepSeek Chat"); let reasoner = ModelConfig::deepseek("deepseek-reasoner", "DeepSeek Reasoner"); }
DeepSeek documents deepseek-chat and deepseek-reasoner as compatibility aliases scheduled for deprecation on 2026-07-24. In DeepSeek's current API, deepseek-chat maps to the non-thinking mode of deepseek-v4-flash, while deepseek-reasoner maps to the thinking mode of deepseek-v4-flash.
yoagent also sends DeepSeek's current request shape:
max_tokens, notmax_completion_tokensthinking: { "type": "enabled" | "disabled" }reasoning_effortwhenThinkingLevelis notOff- DeepSeek cache hit/miss usage fields when present
For legacy aliases, set ThinkingLevel to match the alias behavior:
#![allow(unused)] fn main() { let chat_agent = Agent::from_config(ModelConfig::deepseek("deepseek-chat", "DeepSeek Chat")) .with_thinking(ThinkingLevel::Off); let reasoner_agent = Agent::from_config(ModelConfig::deepseek("deepseek-reasoner", "DeepSeek Reasoner")) .with_thinking(ThinkingLevel::High); }
Older DeepSeek reasoning models had stricter feature limits than the current V4 API. In particular, historical deepseek-reasoner documentation did not support function calling. If you need tools, prefer current V4 model IDs unless you have tested the legacy alias for your workflow.
Compat Flags Without Constructors
OpenAiCompat also has quirk presets such as OpenAiCompat::cerebras() and OpenAiCompat::openrouter(). Those are compatibility profiles, not full ModelConfig constructors. To use them, call ModelConfig::openai_compat(...) with the provider name, base URL, and compat value you need.
Anthropic Provider
AnthropicProvider implements the Anthropic Messages API with SSE streaming.
Usage
#![allow(unused)] fn main() { use yoagent::provider::ModelConfig; let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")); }
Features
Streaming SSE
Uses reqwest-eventsource to parse Anthropic's SSE stream. Events handled:
message_start— Input token usage, cache statscontent_block_start— Text, thinking, or tool_use blockcontent_block_delta— Text, thinking, input JSON, or signature deltascontent_block_stop— Block completemessage_delta— Stop reason, output usagemessage_stop— Stream complete
Thinking
Set thinking_level to enable thinking. By default the provider sends
adaptive thinking (thinking: {"type": "adaptive"}), which the current
model generation requires (Claude Fable 5, Opus 4.7/4.8, Sonnet 5 reject
budget-based thinking with a 400). The level maps to an output_config.effort
hint:
| Level | Effort |
|---|---|
Minimal, Low | low |
Medium | medium |
High | high |
For pre-4.6 models, opt into legacy budget-based thinking via
AnthropicCompat::legacy():
#![allow(unused)] fn main() { let mut config = ModelConfig::anthropic("claude-sonnet-4-5", "Claude Sonnet 4.5"); config.anthropic = Some(AnthropicCompat::legacy()); }
Legacy budgets: Minimal/Low 1,024 (the API minimum), Medium 2,048,
High 8,192. max_tokens is automatically raised above the budget when
needed.
Thinking content is streamed as Content::Thinking with a cryptographic signature for verification.
Refusals
Models with safety classifiers (e.g. Claude Fable 5) can decline a request
with stop_reason: "refusal". The provider maps this to StopReason::Refusal;
the agent loop stops the turn like a normal Stop, and callers can match on
the variant to retry on a fallback model.
Cache Control
Automatic prompt caching via cache_control markers:
- System prompt: Always cached with
{"type": "ephemeral"} - Second-to-last message: Gets
cache_controlon its last content block, creating a cache breakpoint
This means on repeated calls, only the latest message is processed at full price.
Configuration
| Setting | Value |
|---|---|
| API URL | {base_url}/messages (default https://api.anthropic.com/v1/messages) |
| API Version | 2023-06-01 |
| Auth Header | x-api-key (or Authorization: Bearer with AnthropicCompat { bearer_auth: true } / a custom authorization header in ModelConfig.headers) |
| Default Max Tokens | request max_tokens, else ModelConfig.max_tokens, else 8,192 |
Setting ModelConfig.base_url retargets the provider at any gateway that
speaks the Anthropic Messages protocol (e.g. OpenCode Zen/Go — see
OpenCode Zen & Go).
Environment Variables
| Variable | Purpose |
|---|---|
ANTHROPIC_API_KEY | API key |
OpenAI Compatible Provider
OpenAiCompatProvider implements the OpenAI Chat Completions API. One implementation covers OpenAI, xAI, Groq, Cerebras, OpenRouter, Mistral, DeepSeek, MiniMax, Z.ai, Qwen, Ollama, and any other compatible API.
For the first-class ModelConfig::* constructors and default model metadata, see Model Presets.
Usage
Requires a ModelConfig with compat flags set in StreamConfig.model_config:
#![allow(unused)] fn main() { use yoagent::provider::ModelConfig; let agent = Agent::from_config(ModelConfig::openai("gpt-5.5", "GPT-5.5")); }
OpenAiCompat Quirk Flags
Different providers have behavioral differences even though they share the same API:
#![allow(unused)] fn main() { pub struct OpenAiCompat { pub supports_store: bool, pub supports_developer_role: bool, pub supports_reasoning_effort: bool, pub supports_thinking_control: bool, pub supports_usage_in_streaming: bool, pub max_tokens_field: MaxTokensField, // MaxTokens or MaxCompletionTokens pub requires_tool_result_name: bool, pub requires_assistant_after_tool_result: bool, pub thinking_format: ThinkingFormat, // OpenAi, Xai, or Qwen } }
Provider Presets
| Provider | Constructor | Key Differences |
|---|---|---|
| OpenAI | OpenAiCompat::openai() | developer role, max_completion_tokens, store, reasoning_effort |
| xAI (Grok) | OpenAiCompat::xai() | reasoning field for thinking (not reasoning_content) |
| Groq | OpenAiCompat::groq() | Standard defaults |
| Cerebras | OpenAiCompat::cerebras() | Standard defaults |
| OpenRouter | OpenAiCompat::openrouter() | max_completion_tokens |
| Mistral | OpenAiCompat::mistral() | max_tokens field |
| DeepSeek | OpenAiCompat::deepseek() | max_tokens, thinking, reasoning_effort, 1M context window |
| MiniMax | OpenAiCompat::minimax() | Standard defaults, 1M context window |
| Z.ai (Zhipu) | OpenAiCompat::zai() | Standard defaults |
| Qwen | OpenAiCompat::qwen() | Qwen reasoning content format, max_tokens, streaming usage |
| Ollama | OpenAiCompat::ollama() | Inserts an empty assistant message after tool result runs |
OpenAiCompat presets are lower-level quirk flags. A provider is first-class when it also has a ModelConfig::* constructor; see Model Presets.
DeepSeek context caching is automatic on DeepSeek's side. yoagent does not send
cache_control markers for DeepSeek, but it does parse DeepSeek's
prompt_cache_hit_tokens and prompt_cache_miss_tokens usage fields into
Usage.cache_read and Usage.input.
Adding a New Compatible Provider
- Add a constructor to
OpenAiCompat:
#![allow(unused)] fn main() { impl OpenAiCompat { pub fn my_provider() -> Self { Self { supports_usage_in_streaming: true, // set flags as needed... ..Default::default() } } } }
- Create a
ModelConfigthat uses it:
#![allow(unused)] fn main() { let config = ModelConfig::openai_compat( "https://api.myprovider.com/v1", "my-model", "my-provider", OpenAiCompat::my_provider(), ); }
Thinking/Reasoning
The ThinkingFormat enum controls how reasoning content is parsed from streams:
ThinkingFormat::OpenAi— Usesreasoning_contentfield (DeepSeek, default)ThinkingFormat::Xai— Usesreasoningfield (Grok)ThinkingFormat::Qwen— Usesreasoning_contentfield (Qwen)
Local Servers (LM Studio, Ollama, llama.cpp, vLLM)
Use ModelConfig::ollama() for Ollama, or ModelConfig::local() for any other local OpenAI-compatible server. No API key required:
#![allow(unused)] fn main() { use yoagent::agent::Agent; use yoagent::provider::ModelConfig; // The `local` provider resolves to an empty API key automatically — none needed. let agent = Agent::from_config(ModelConfig::local("http://localhost:1234/v1", "my-model")); }
For Ollama:
#![allow(unused)] fn main() { let agent = Agent::from_config(ModelConfig::ollama("http://localhost:11434/v1", "llama3.1:8b")); }
Or via the CLI example:
cargo run --example cli -- --api-url http://localhost:1234/v1 --model my-model
For locally deployed open-source model families, keep the local endpoint and choose the model-family compat profile:
#![allow(unused)] fn main() { let qwen_local = ModelConfig::openai_compat( "http://localhost:1234/v1", "qwen3-local", "qwen", OpenAiCompat::qwen(), ); }
Serving-layer quirks and model-family quirks can be combined because OpenAiCompat fields are public:
#![allow(unused)] fn main() { let mut compat = OpenAiCompat::qwen(); compat.requires_assistant_after_tool_result = true; let qwen_on_ollama = ModelConfig::openai_compat( "http://localhost:11434/v1", "qwen2.5-coder:7b", "ollama", compat, ); }
GitHub Copilot (bring-your-own-token)
Terms of service.
api.githubcopilot.comis intended for use through official GitHub Copilot editor integrations. Accessing it from a third-party agent is against GitHub's Copilot terms of service and may result in token revocation or account suspension. yoagent does not ship a first-class Copilot preset for this reason. The configuration below is documented only for users who understand and accept that risk. Use at your own discretion.
Copilot's chat endpoint is OpenAI Chat Completions–shaped, so it works with
OpenAiCompatProvider given the right base URL, integration headers, and a valid
Copilot token as the API key:
#![allow(unused)] fn main() { use yoagent::agent::Agent; use yoagent::provider::{ModelConfig, OpenAiCompat}; let mut config = ModelConfig::openai_compat( "https://api.githubcopilot.com", "gpt-5.5", "copilot", OpenAiCompat::openai(), ); // Copilot fingerprints clients via these headers; they are required. config.headers.insert("Copilot-Integration-Id".into(), "vscode-chat".into()); config.headers.insert("Editor-Version".into(), "Neovim/0.10.0".into()); let agent = Agent::from_config(config) .with_api_key(copilot_token); // see below }
The API key is a short-lived Copilot token, not your GitHub token. You obtain it by
exchanging a GitHub OAuth token (from the device-login flow, or from the local Copilot
config under ~/.config/github-copilot/) at
https://api.github.com/copilot_internal/v2/token. That token expires after ~25–30
minutes.
yoagent has no built-in credential refresh — api_key is static for the life of the
provider (Authorization: Bearer {api_key}). For anything longer than a single short
turn, you must exchange and refresh the token yourself and rebuild the agent's config
with a fresh token before it expires; otherwise long runs will fail with 401.
Auth
Uses Authorization: Bearer {api_key} header. Extra headers can be added via ModelConfig.headers.
Google Gemini Provider
Two providers for Google's Gemini models:
GoogleProvider— Google AI Studio (Generative AI API)GoogleVertexProvider— Google Cloud Vertex AI
Google AI Studio
#![allow(unused)] fn main() { use yoagent::provider::ModelConfig; let agent = Agent::from_config(ModelConfig::google("gemini-2.5-flash", "Gemini 2.5 Flash")); }
API Details
- Endpoint:
{base_url}/v1beta/models/{model}:streamGenerateContent?alt=sse&key={api_key} - Auth: API key as query parameter
- Default base URL:
https://generativelanguage.googleapis.com - Default context window: 1,000,000 tokens
Message Format
Google uses a different message format than OpenAI/Anthropic:
| yoagent | Google API |
|---|---|
user role | user role |
assistant role | model role |
Content::Text | {"text": "..."} |
Content::Image | {"inlineData": {...}} |
Content::ToolCall | {"functionCall": {...}} |
Message::ToolResult | {"functionResponse": {...}} |
| System prompt | systemInstruction field |
| Tools | tools[].functionDeclarations[] |
Streaming
Uses SSE format (alt=sse). Each chunk contains candidates with content.parts and optional usageMetadata.
Google Vertex AI
GoogleVertexProvider uses the same message format but with Vertex AI authentication and endpoints.
- Protocol:
ApiProtocol::GoogleVertex - Auth: OAuth2 / service account credentials
- Endpoint pattern:
https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}/publishers/google/models/{model}:streamGenerateContent
Amazon Bedrock Provider
BedrockProvider implements the AWS Bedrock ConverseStream API.
Usage
#![allow(unused)] fn main() { use yoagent::provider::{ApiProtocol, ModelConfig}; // Bedrock has no dedicated ModelConfig preset — build one with `custom`. let agent = Agent::from_config(ModelConfig::custom( ApiProtocol::BedrockConverseStream, "bedrock", "https://bedrock-runtime.us-east-1.amazonaws.com", "anthropic.claude-opus-4-8", "Claude Opus 4.8", )) .with_api_key("ACCESS_KEY:SECRET_KEY"); // or ACCESS_KEY:SECRET_KEY:SESSION_TOKEN }
Authentication
The api_key field uses a colon-separated format:
{access_key_id}:{secret_access_key}
{access_key_id}:{secret_access_key}:{session_token}
Alternatively, provide pre-computed auth headers via ModelConfig.headers or use an IAM proxy that handles SigV4 signing.
API Details
- Endpoint:
{base_url}/model/{model}/converse-stream - Default base URL:
https://bedrock-runtime.us-east-1.amazonaws.com - Protocol:
ApiProtocol::BedrockConverseStream
Message Format
Bedrock uses its own content block format:
| yoagent | Bedrock API |
|---|---|
Content::Text | {"text": "..."} |
Content::Image | {"image": {"format": "...", "source": {"bytes": "..."}}} |
Content::ToolCall | {"toolUse": {"toolUseId": "...", "name": "...", "input": ...}} |
Message::ToolResult | {"toolResult": {"toolUseId": "...", "content": [...], "status": "success"}} |
| System prompt | system array of text blocks |
| Tools | toolConfig.tools[].toolSpec |
| Max tokens | inferenceConfig.maxTokens |
Stream Events
Bedrock's ConverseStream returns these event types:
contentBlockStart— New content block (text or tool use)contentBlockDelta— Text or tool use input deltacontentBlockStop— Block completemessageStop— Stop reason (end_turn,max_tokens,tool_use)metadata— Token usage
Azure OpenAI Provider
AzureOpenAiProvider implements the OpenAI Responses API format with Azure-specific authentication and URL patterns.
Usage
#![allow(unused)] fn main() { use yoagent::provider::{ApiProtocol, ModelConfig}; // Azure has no dedicated ModelConfig preset — build one with `custom`. // The provider ("azure") resolves the key from AZURE_OPENAI_API_KEY. let agent = Agent::from_config(ModelConfig::custom( ApiProtocol::AzureOpenAiResponses, "azure", "https://{resource}.openai.azure.com/openai/deployments/{deployment}", "gpt-5.5", "GPT-5.5", )); }
Authentication
Uses the api-key header (not Authorization: Bearer):
api-key: {your_api_key}
Additional headers can be set via ModelConfig.headers (e.g., for Azure AD Bearer tokens).
URL Format
https://{resource}.openai.azure.com/openai/deployments/{deployment}
Set this as ModelConfig.base_url. The provider appends /responses?api-version=2025-01-01-preview.
API Details
- Protocol:
ApiProtocol::AzureOpenAiResponses - Format: OpenAI Responses API (not Chat Completions)
- Streaming: SSE with event types:
response.output_text.delta— Text contentresponse.function_call_arguments.start— Tool call startresponse.function_call_arguments.delta— Tool call argumentsresponse.completed— Final usage data
Message Format
Uses the Responses API input format:
| yoagent | Azure Responses API |
|---|---|
| User message | {"role": "user", "content": "..."} |
| Assistant text | {"type": "message", "role": "assistant", "content": [{"type": "output_text", ...}]} |
| Tool call | {"type": "function_call", "call_id": "...", "name": "...", "arguments": "..."} |
| Tool result | {"type": "function_call_output", "call_id": "...", "output": "..."} |
| System prompt | instructions field |
OpenCode Zen & Go
OpenCode Zen (pay-per-use) and OpenCode Go (subscription) are model gateways run by the OpenCode team. Both are supported through the ModelConfig::opencode_zen() and ModelConfig::opencode_go() presets.
The gateways serve different model families over different protocols. The presets select the protocol automatically from the model id:
| Gateway | Model family | Protocol | Pair with |
|---|---|---|---|
| Zen | gpt-* | OpenAI Responses | OpenAiResponsesProvider |
| Zen | claude-*, qwen* | Anthropic Messages | AnthropicProvider |
| Zen | DeepSeek, MiniMax, GLM, Kimi, ... | Chat Completions | OpenAiCompatProvider |
| Go | qwen*, minimax-* | Anthropic Messages | AnthropicProvider |
| Go | GLM, Kimi, DeepSeek, MiMo, ... | Chat Completions | OpenAiCompatProvider |
Gemini models on Zen are not supported — Zen serves them over a Google-native endpoint shape yoagent does not target. A gemini-* id falls through to Chat Completions (with a warning logged) and will fail at request time.
The routing table mirrors the Zen/Go endpoint docs as of mid-2026. OpenCode can change gateway-side routing at any time — if a model errors, verify its protocol against {base}/models.
Usage
Agent::from_config selects the built-in provider from the preset's protocol
(config.api) and resolves the key from OPENCODE_API_KEY, so the same call
works whichever model family you pick:
#![allow(unused)] fn main() { use yoagent::provider::ModelConfig; use yoagent::Agent; // Chat-completions model (GLM, Kimi, DeepSeek, ...) let agent = Agent::from_config(ModelConfig::opencode_zen("glm-5.2")); // Claude/Qwen model — Anthropic Messages protocol let agent = Agent::from_config(ModelConfig::opencode_zen("claude-sonnet-5")); }
For OpenCode Go, use ModelConfig::opencode_go("kimi-k2.7-code") — the base URL and protocol map differ, the usage pattern is identical.
Authentication
Both gateways use Authorization: Bearer {api_key}. For the Anthropic-protocol models the presets set AnthropicCompat { bearer_auth: true, .. } so the Anthropic provider sends Bearer auth instead of its native x-api-key header.
Get an API key by signing in at opencode.ai (Zen) or subscribing to Go.
Defaults
The presets use conservative defaults (128K context window, 16K max output). Override the fields for models with larger limits:
#![allow(unused)] fn main() { let mut config = ModelConfig::opencode_zen("kimi-k2.7-code"); config.context_window = 256_000; }
Endpoints
- Zen:
https://opencode.ai/zen/v1/{chat/completions | messages | responses} - Go:
https://opencode.ai/zen/go/v1/{chat/completions | messages}
Model list and metadata: https://opencode.ai/zen/v1/models and https://opencode.ai/zen/go/v1/models.
Built-in Tools
yoagent ships with six coding-oriented tools. Get them all with default_tools():
#![allow(unused)] fn main() { use yoagent::tools::default_tools; let tools = default_tools(); }
BashTool
Execute shell commands with timeout and output capture.
- Name:
bash - Parameters:
command(string, required)
Configuration
#![allow(unused)] fn main() { pub struct BashTool { pub cwd: Option<String>, // Working directory pub timeout: Duration, // Default: 120s pub max_output_bytes: usize, // Default: 256KB pub deny_patterns: Vec<String>, // Blocked commands pub confirm_fn: Option<ConfirmFn>, // Confirmation callback } }
Default deny patterns: rm -rf /, rm -rf /*, mkfs, dd if=, fork bomb.
Example
#![allow(unused)] fn main() { let bash = BashTool::default(); // Or customize: let bash = BashTool { cwd: Some("/workspace".into()), timeout: Duration::from_secs(60), ..Default::default() }; }
ReadFileTool
Read file contents with optional line range.
- Name:
read_file - Parameters:
path(required),offset(optional, 1-indexed line),limit(optional, number of lines)
Configuration
#![allow(unused)] fn main() { pub struct ReadFileTool { pub max_bytes: usize, // Default: 1MB pub allowed_paths: Vec<String>, // Path restrictions (empty = no restriction) } }
WriteFileTool
Write content to a file. Creates parent directories automatically.
- Name:
write_file - Parameters:
path(required),content(required)
EditFileTool
Surgical search/replace edits. The most important tool for coding agents — instead of rewriting entire files, the agent specifies exact text to find and replace.
- Name:
edit_file - Parameters:
path(required),old_text(required),new_text(required)
The old_text must match exactly, including whitespace and indentation.
ListFilesTool
List files and directories with optional glob filtering.
- Name:
list_files - Parameters:
path(optional, default:.),pattern(optional glob)
Configuration
#![allow(unused)] fn main() { pub struct ListFilesTool { pub max_results: usize, // Default: 200 pub timeout: Duration, // Default: 10s } }
Uses find or fd for efficient traversal.
SearchTool
Search files using grep (or ripgrep if available).
- Name:
search - Parameters:
pattern(required, regex),path(optional root directory)
Configuration
#![allow(unused)] fn main() { pub struct SearchTool { pub root: Option<String>, // Root directory pub max_results: usize, // Default: 50 pub timeout: Duration, // Default: 30s } }
Returns matching lines with file paths and line numbers.
SharedStateTool
Read and write named variables in a shared key-value store. This tool is not included in default_tools() — it is automatically injected into sub-agents when you call SubAgentTool::with_shared_state().
- Name:
shared_state - Parameters:
action(required:get,set,list,remove),key(required for get/set/remove),value(required for set)
| Action | Description |
|---|---|
get | Returns the value for a key, or error if not found |
set | Stores a value, returns confirmation with byte size |
list | Lists all keys with their byte sizes |
remove | Deletes a key |
See Sub-Agents: Shared State for usage details.
Configuration
AgentLoopConfig
The main configuration for the agent loop:
#![allow(unused)] fn main() { pub struct AgentLoopConfig { pub provider: Arc<dyn StreamProvider>, pub model: String, pub api_key: String, pub thinking_level: ThinkingLevel, pub max_tokens: Option<u32>, pub temperature: Option<f32>, pub model_config: Option<ModelConfig>, pub convert_to_llm: Option<ConvertToLlmFn>, pub transform_context: Option<TransformContextFn>, pub get_steering_messages: Option<GetMessagesFn>, pub get_follow_up_messages: Option<GetMessagesFn>, pub context_config: Option<ContextConfig>, pub compaction_strategy: Option<Arc<dyn CompactionStrategy>>, pub execution_limits: Option<ExecutionLimits>, pub cache_config: CacheConfig, pub tool_execution: ToolExecutionStrategy, pub retry_config: RetryConfig, pub before_turn: Option<BeforeTurnFn>, pub after_turn: Option<AfterTurnFn>, pub on_error: Option<OnErrorFn>, pub input_filters: Vec<Arc<dyn InputFilter>>, pub turn_delay: Option<Duration>, } }
StreamConfig
Passed to StreamProvider::stream():
#![allow(unused)] fn main() { pub struct StreamConfig { pub model: String, pub system_prompt: String, pub messages: Vec<Message>, pub tools: Vec<ToolDefinition>, pub thinking_level: ThinkingLevel, pub api_key: String, pub max_tokens: Option<u32>, pub temperature: Option<f32>, pub model_config: Option<ModelConfig>, pub cache_config: CacheConfig, } }
ContextConfig
Controls context window compaction:
#![allow(unused)] fn main() { pub struct ContextConfig { pub max_context_tokens: usize, // Default: 100,000 pub system_prompt_tokens: usize, // Default: 4,000 pub keep_recent: usize, // Default: 10 pub keep_first: usize, // Default: 2 pub tool_output_max_lines: usize, // Default: 50 } }
When context_config is not explicitly set, it is automatically derived from ModelConfig.context_window (80% for context, 20% reserved for output). If neither is set, ContextConfig::default() (100K) is used.
#![allow(unused)] fn main() { // Derive from a model's context window: let config = ContextConfig::from_context_window(200_000); // config.max_context_tokens == 160_000 }
ExecutionLimits
Prevents runaway agents:
#![allow(unused)] fn main() { pub struct ExecutionLimits { pub max_turns: usize, // Default: 50 pub max_total_tokens: usize, // Default: 1,000,000 pub max_duration: Duration, // Default: 600s } }
ThinkingLevel
#![allow(unused)] fn main() { pub enum ThinkingLevel { Off, // No thinking (default) Minimal, // Anthropic: effort "low" (adaptive) / 1,024-token budget (legacy) Low, // Anthropic: effort "low" / 1,024 Medium, // Anthropic: effort "medium" / 2,048 High, // Anthropic: effort "high" / 8,192 } }
OpenAI-family providers map these levels to reasoning_effort where the
compat flags enable it; the Google and Bedrock providers currently ignore
thinking_level.
CostConfig
Token pricing per million:
#![allow(unused)] fn main() { pub struct CostConfig { pub input_per_million: f64, pub output_per_million: f64, pub cache_read_per_million: f64, pub cache_write_per_million: f64, } }
ModelConfig Presets
yoagent provides first-class ModelConfig::* constructors for Anthropic, OpenAI, Google Gemini, xAI, Groq, DeepSeek, Mistral, MiniMax, Z.ai, Qwen, Ollama, and local OpenAI-compatible servers.
See Model Presets for the full table of constructors, default base URLs, context windows, and DeepSeek legacy alias notes.
API Reference
Top-Level Functions
agent_loop()
#![allow(unused)] fn main() { pub async fn agent_loop( prompts: Vec<AgentMessage>, context: &mut AgentContext, config: &AgentLoopConfig, tx: mpsc::UnboundedSender<AgentEvent>, cancel: CancellationToken, ) -> Vec<AgentMessage> }
Start an agent loop with new prompt messages. Returns all messages generated during the run.
agent_loop_continue()
#![allow(unused)] fn main() { pub async fn agent_loop_continue( context: &mut AgentContext, config: &AgentLoopConfig, tx: mpsc::UnboundedSender<AgentEvent>, cancel: CancellationToken, ) -> Vec<AgentMessage> }
Resume from existing context. The last message must not be an assistant message.
default_tools()
#![allow(unused)] fn main() { pub fn default_tools() -> Vec<Box<dyn AgentTool>> }
Returns: BashTool, ReadFileTool, WriteFileTool, EditFileTool, ListFilesTool, SearchTool.
Agent Struct
High-level stateful wrapper around the agent loop.
Construction
#![allow(unused)] fn main() { let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")); }
| Signature | Description |
|---|---|
Agent::from_config(config: ModelConfig) -> Self | Build from a ModelConfig — auto-selects the built-in provider for the config's protocol and resolves the API key from the provider's conventional env var (primary constructor) |
Agent::from_provider(provider: impl StreamProvider + 'static, config: ModelConfig) -> Self | Build from an explicit provider plus its ModelConfig (custom providers and test doubles — pair with ModelConfig::mock()) |
Agent::from_config_with(registry: &ProviderRegistry, config: ModelConfig) -> Result<Self, AgentBuildError> | Like from_config, but resolves the provider from a caller-supplied registry |
Builder Methods
All return Self for chaining (unless noted as Result).
Core
| Method | Description |
|---|---|
with_system_prompt(prompt) -> Self | Set the system prompt |
with_api_key(key) -> Self | Override the env-resolved API key |
with_thinking(level: ThinkingLevel) -> Self | Set thinking level (Off, Minimal, Low, Medium, High) |
with_max_tokens(max: u32) -> Self | Set max output tokens |
Tools & Integrations
| Method | Description |
|---|---|
with_tools(tools: Vec<Box<dyn AgentTool>>) -> Self | Set tools (replaces existing) |
with_sub_agent(sub: SubAgentTool) -> Self | Add a sub-agent tool |
with_skills(skills: SkillSet) -> Self | Load skills and append their index to the system prompt |
async with_mcp_server_stdio(command, args, env) -> Result<Self, McpError> | Connect to MCP server via stdio and add its tools |
async with_mcp_server_http(url) -> Result<Self, McpError> | Connect to MCP server via HTTP and add its tools |
async with_openapi_file(path, config, filter) -> Result<Self, OpenApiError> | Load tools from an OpenAPI spec file (requires openapi feature) |
async with_openapi_url(url, config, filter) -> Result<Self, OpenApiError> | Fetch spec from URL and add tools (requires openapi feature) |
with_openapi_spec(spec_str, config, filter) -> Result<Self, OpenApiError> | Parse spec string and add tools (requires openapi feature) |
Context & Limits
| Method | Description |
|---|---|
with_context_config(config: ContextConfig) -> Self | Set context compaction config |
with_execution_limits(limits: ExecutionLimits) -> Self | Set execution limits (max turns, tokens, duration) |
with_compaction_strategy(strategy: impl CompactionStrategy) -> Self | Set a custom compaction strategy |
without_context_management() -> Self | Disable automatic context compaction and execution limits |
Behavior
| Method | Description |
|---|---|
with_messages(msgs: Vec<AgentMessage>) -> Self | Pre-load message history |
with_cache_config(config: CacheConfig) -> Self | Set prompt caching configuration |
with_tool_execution(strategy: ToolExecutionStrategy) -> Self | Set tool execution strategy (Parallel, Sequential, Batched) |
with_retry_config(config: RetryConfig) -> Self | Set retry configuration |
with_input_filter(filter: impl InputFilter) -> Self | Add an input filter (runs on user messages before LLM call) |
Callbacks
| Method | Description |
|---|---|
on_before_turn(f: Fn(&[AgentMessage], usize) -> bool) -> Self | Called before each LLM call; return false to abort |
on_after_turn(f: Fn(&[AgentMessage], &Usage)) -> Self | Called after each LLM response and tool execution |
on_error(f: Fn(&str)) -> Self | Called when the LLM returns StopReason::Error |
Prompting
| Method | Description |
|---|---|
async prompt(text) -> UnboundedReceiver<AgentEvent> | Send a text prompt; spawns the loop concurrently and returns the event stream immediately for real-time consumption |
async prompt_messages(messages) -> UnboundedReceiver<AgentEvent> | Send messages as prompt; spawns concurrently, returns event stream immediately |
async prompt_with_sender(text, tx: UnboundedSender<AgentEvent>) | Send a text prompt, streaming events to a caller-provided sender; blocks until the loop finishes |
async prompt_messages_with_sender(messages, tx) | Send messages, streaming events to a caller-provided sender; blocks until the loop finishes |
async continue_loop() -> UnboundedReceiver<AgentEvent> | Resume from current context; spawns concurrently, returns event stream immediately |
async continue_loop_with_sender(tx: UnboundedSender<AgentEvent>) | Resume from current context, streaming events to a caller-provided sender; blocks until the loop finishes |
async finish() | Await a pending spawned loop and restore tools/messages/state. Called automatically at the start of each prompt method |
State Access
| Method | Description |
|---|---|
messages() -> &[AgentMessage] | Get the full message history |
is_streaming() -> bool | Whether the agent is currently running |
State Mutation
| Method | Description |
|---|---|
set_tools(tools: Vec<Box<dyn AgentTool>>) | Replace the tool set |
clear_messages() | Clear all messages |
append_message(msg: AgentMessage) | Add a message to history |
replace_messages(msgs: Vec<AgentMessage>) | Replace all messages |
save_messages() -> Result<String, serde_json::Error> | Serialize message history to JSON |
restore_messages(json: &str) -> Result<(), serde_json::Error> | Restore message history from JSON |
Steering & Follow-Up Queues
| Method | Description |
|---|---|
steer(msg: AgentMessage) | Queue a steering message (interrupts mid-tool-execution) |
follow_up(msg: AgentMessage) | Queue a follow-up message (processed after agent finishes) |
clear_steering_queue() | Clear pending steering messages |
clear_follow_up_queue() | Clear pending follow-up messages |
clear_all_queues() | Clear both queues |
steer_all(msgs: Vec<AgentMessage>) | Queue multiple steering messages under one lock |
follow_up_all(msgs: Vec<AgentMessage>) | Queue multiple follow-up messages under one lock |
steering_queue_snapshot() -> Vec<AgentMessage> | Copy of pending steering messages (does not consume) |
follow_up_queue_snapshot() -> Vec<AgentMessage> | Copy of pending follow-up messages |
steering_queue_len() -> usize | Number of pending steering messages |
follow_up_queue_len() -> usize | Number of pending follow-up messages |
take_steering_queue() -> Vec<AgentMessage> | Atomically drain and return pending steering messages (messages already picked up by the loop are not included) |
take_follow_up_queue() -> Vec<AgentMessage> | Atomically drain and return pending follow-up messages |
set_steering_mode(mode: QueueMode) | Set delivery mode: OneAtATime or All |
set_follow_up_mode(mode: QueueMode) | Set delivery mode: OneAtATime or All |
Control
| Method | Description |
|---|---|
abort() | Cancel the current run via CancellationToken |
async reset() | Cancel any pending loop, recover tools, clear all state (messages, queues, streaming flag) |
SubAgentTool
Delegates tasks to a child agent loop.
Construction
#![allow(unused)] fn main() { // Provider auto-selected from the config's protocol; env key resolved automatically: let sub = SubAgentTool::from_config("name", ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")); // Or pass an explicit provider Arc (custom providers, or a shared handle across sub-agents): let sub = SubAgentTool::from_provider("name", Arc::new(provider), ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")); }
Builder Methods
All return Self for chaining.
| Method | Description |
|---|---|
with_description(desc) -> Self | What the parent LLM sees (helps it decide when to delegate) |
with_system_prompt(prompt) -> Self | The sub-agent's own instructions |
with_api_key(key) -> Self | Override the env-resolved API key |
with_tools(tools: Vec<Arc<dyn AgentTool>>) -> Self | Tools available to the sub-agent |
with_shared_state(state: SharedState) -> Self | Attach a shared key-value store (injects shared_state tool automatically) |
with_max_turns(N) -> Self | Turn limit (default: 10) |
with_thinking(level: ThinkingLevel) -> Self | Enable extended thinking |
with_max_tokens(max: u32) -> Self | Set max output tokens |
with_cache_config(config: CacheConfig) -> Self | Prompt caching settings |
with_tool_execution(strategy: ToolExecutionStrategy) -> Self | Tool execution strategy (Parallel, Sequential, Batched) |
with_retry_config(config: RetryConfig) -> Self | Custom retry configuration |
with_turn_delay(delay: Duration) -> Self | Inter-turn delay to throttle API calls (skips first turn) |
SharedState
Pluggable key-value store for sub-agent communication. Backed by a SharedStateBackend trait.
Construction
#![allow(unused)] fn main() { use yoagent::shared_state::{SharedState, FileBackend}; let state = SharedState::new(); // MemoryBackend, 10MB cap let state = SharedState::with_max_bytes(50 * 1024 * 1024); // MemoryBackend, 50MB cap let state = SharedState::with_backend(FileBackend::new("./state-dir")); // FileBackend }
Methods
| Method | Description |
|---|---|
async get(key) -> Option<String> | Read a value by key |
async set(key, value) -> Result<(), SharedStateError> | Store a value |
async remove(key) -> bool | Delete a key, returns whether it existed |
async keys() -> Vec<String> | List all keys |
async summary() -> String | Human-readable summary of keys and sizes |
Built-in Backends
| Backend | Description |
|---|---|
MemoryBackend | In-memory HashMap with byte capacity limit (default) |
FileBackend | One file per key, percent-encoded filenames, persistent |
Custom Backends
Implement the SharedStateBackend trait:
#![allow(unused)] fn main() { #[async_trait::async_trait] pub trait SharedStateBackend: Send + Sync { async fn get(&self, key: &str) -> Result<Option<String>, SharedStateError>; async fn set(&self, key: &str, value: String) -> Result<(), SharedStateError>; async fn remove(&self, key: &str) -> Result<bool, SharedStateError>; async fn keys(&self) -> Result<Vec<String>, SharedStateError>; async fn summary(&self) -> Result<String, SharedStateError>; } }
Re-exports
The crate re-exports key types from lib.rs:
#![allow(unused)] fn main() { pub use agent::Agent; pub use agent_loop::{agent_loop, agent_loop_continue}; pub use types::*; // Message, Content, AgentMessage, AgentEvent, etc. }
Architecture Overview
Layered Design
yoagent is organized as three conceptual layers within a single crate. Dependencies flow strictly downward — upper layers use lower layers, never the reverse.
┌─────────────────────────────────────────────┐
│ Layer 3: Orchestration (planned) │
│ Multi-agent, delegation, work modes │
├─────────────────────────────────────────────┤
│ Layer 2: Agent + Providers │
│ Concrete providers, tools, retry, caching, │
│ context management, MCP │
├─────────────────────────────────────────────┤
│ Layer 1: Core Loop │
│ agent_loop, types, traits │
│ Provider-agnostic. Tool-agnostic. │
└─────────────────────────────────────────────┘
Layer 1: Core Loop
The pure agent loop. No opinions about LLMs, no built-in tools. Just the control flow.
Modules: types.rs, agent_loop.rs, provider/traits.rs
Owns:
agent_loop()/agent_loop_continue()— the loop itselfAgentTooltrait — interface tools must implementStreamProvidertrait — interface providers must implementAgentMessage,AgentEvent,StreamDelta— message & event typesAgentContext— system prompt + messages + tools- Tool execution strategies (parallel/sequential/batched)
- Streaming tool output (
ToolUpdateFn) - Steering & follow-up message injection
Does not own: Any concrete provider or tool implementation.
Layer 2: Agent + Providers
Batteries-included single-agent layer. Most users interact with this.
Modules: agent.rs, context.rs, retry.rs, provider/*.rs, tools/*.rs, mcp/*.rs
Adds on top of Layer 1:
- Concrete providers — Anthropic, OpenAI-compat, Google, Azure, Bedrock, Vertex
- Provider registry — dispatch by API protocol
- Prompt caching — automatic cache breakpoint placement
- Retry with backoff — exponential, jitter, respects retry-after
- Context management — token estimation, smart truncation, execution limits
- Built-in tools — bash, read_file, write_file, edit_file, list_files, search
- MCP client — stdio + HTTP transports, tool adapter
Agentstruct — stateful builder wrapping it all together
Layer 3: Orchestration (planned)
Multi-agent coordination. Not yet implemented — the architecture is designed to support it when needed.
Planned capabilities:
Orchestratorstruct — spawn, delegate, and coordinate multiple agents- Work modes:
- Interactive — multi-turn, human in the loop (current default)
- Autonomous — runs to completion without input (background tasks, CI)
- Pipeline — input → output, chainable (scan → fix → verify)
- Supervisor — delegates to other agents, synthesizes results
- Fan-out — same task to multiple agents (different providers for diversity)
- Pipeline chaining — output of agent A feeds input of agent B
- Agent communication through the orchestrator event bus
Why not yet: Multi-agent orchestration adds complexity. The single-agent loop handles 95% of use cases. Layer 3 will be built when a concrete use case drives it, not speculatively.
Module Layout
yoagent/
├── src/
│ ├── lib.rs # Public re-exports
│ │
│ │── Layer 1: Core Loop ─────────────────────
│ ├── types.rs # Message, Content, AgentTool, AgentEvent
│ ├── agent_loop.rs # Core loop: prompt → LLM → tools → repeat
│ │
│ │── Layer 2: Agent + Providers ─────────────
│ ├── agent.rs # Agent struct (stateful wrapper)
│ ├── context.rs # Token estimation, compaction, limits
│ ├── retry.rs # Retry with exponential backoff
│ ├── provider/
│ │ ├── traits.rs # StreamProvider trait, StreamEvent, ProviderError
│ │ ├── model.rs # ModelConfig, ApiProtocol, OpenAiCompat
│ │ ├── registry.rs # ProviderRegistry (protocol → provider)
│ │ ├── anthropic.rs # Anthropic Messages API
│ │ ├── openai_compat.rs # OpenAI Chat Completions (15+ providers)
│ │ ├── openai_responses.rs # OpenAI Responses API
│ │ ├── google.rs # Google Generative AI
│ │ ├── google_vertex.rs # Google Vertex AI
│ │ ├── bedrock.rs # AWS Bedrock ConverseStream
│ │ ├── azure_openai.rs # Azure OpenAI
│ │ ├── mock.rs # Mock provider for testing
│ │ └── sse.rs # SSE utilities
│ ├── tools/
│ │ ├── bash.rs # BashTool
│ │ ├── file.rs # ReadFileTool, WriteFileTool
│ │ ├── edit.rs # EditFileTool
│ │ ├── list.rs # ListFilesTool
│ │ └── search.rs # SearchTool
│ └── mcp/
│ ├── client.rs # MCP client (stdio + HTTP)
│ ├── tool_adapter.rs # McpToolAdapter (MCP tool → AgentTool)
│ ├── transport.rs # Transport implementations
│ └── types.rs # MCP protocol types
Data Flow
┌─────────────┐
│ Caller │
└──────┬──────┘
│ prompt / prompt_messages
┌──────▼──────┐
│ Agent │ Layer 2: stateful wrapper
│ (agent.rs) │ Manages queues, tools, state
└──────┬──────┘
│
┌──────▼──────┐
│ agent_loop │ Layer 1: core loop
│ │ Prompt → LLM → Tools → Repeat
└──┬───────┬──┘
│ │
┌────────▼──┐ ┌──▼────────┐
│ Provider │ │ Tools │ Layer 2: implementations
│ .stream() │ │ .execute()│
└────────┬──┘ └──┬────────┘
│ │
┌────────▼──┐ ┌──▼────────┐
│ LLM API │ │ OS / FS │
│ (HTTP) │ │ (shell) │
└───────────┘ └───────────┘
Events flow back via mpsc::UnboundedSender<AgentEvent>
How Providers Plug In
- Implement
StreamProvidertrait (Layer 1 interface) - Register with
ProviderRegistryunder anApiProtocol(Layer 2) - Set
ModelConfig.apito match that protocol - The registry dispatches
stream()calls to the right provider
Each provider translates between yoagent's Message/Content types and the provider's native API format. All providers emit StreamEvents through the channel for real-time updates.
How Tools Plug In
- Implement
AgentTooltrait (Layer 1 interface) - Add to the tools vec (via
default_tools()or custom) - The agent loop converts tools to
ToolDefinition(name, description, schema) for the LLM - When the LLM returns
Content::ToolCall, the loop finds the matching tool and callsexecute() - Results are wrapped in
Message::ToolResultand added to context
Tools receive a CancellationToken child token — they should check it for cooperative cancellation during long operations.
Design Principles
- Layers are conceptual, not physical. One crate, clean module boundaries, no feature flags needed.
- Dependencies flow down. Layer 1 never imports from Layer 2. Layer 2 never imports from Layer 3.
- Layer 1 is stable. The core loop and traits change rarely. New features are added in Layer 2 or 3.
- Build what's needed. Layer 3 is designed but not implemented. It will be built when a use case demands it, not speculatively.
- Simple over clever. A straightforward loop with good defaults beats an elegant abstraction nobody can debug.