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 AgentEvent stream for UI updates (text deltas, thinking, tool execution)
  • Multi-provider — Anthropic, OpenAI, Google Gemini, Amazon Bedrock, Azure OpenAI, and any OpenAI-compatible API
  • Tool systemAgentTool trait 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
  • CancellationCancellationToken-based abort at any point
  • Builder pattern — Ergonomic Agent struct with chainable configuration

Ecosystem

yoagent is part of the Yolog ecosystem. It powers the agent backend for Yolog applications.

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:

CratePurpose
tokioAsync runtime (full features)
serde / serde_jsonSerialization
reqwestHTTP client for provider APIs
reqwest-eventsourceSSE streaming
async-traitAsync trait support
tokio-utilCancellationToken
thiserrorError types
tracingLogging

Feature Flags

All providers and built-in tools are included by default. Optional features:

FeatureDependenciesDescription
openapiopenapiv3, serde_yaml_ngAuto-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>,
}
}
FieldPurpose
providerThe StreamProvider implementation to use
modelModel identifier (e.g., "claude-sonnet-5")
api_keyAPI key for the provider
thinking_levelOff, Minimal, Low, Medium, High
model_configOptional ModelConfig for multi-provider support (base URL, headers, compat flags)
convert_to_llmCustom AgentMessage[] → Message[] conversion
transform_contextPre-processing hook for context pruning
get_steering_messagesReturns user interruptions during tool execution
get_follow_up_messagesReturns queued work after agent would stop
context_configToken budget and compaction settings. Auto-derived from model_config.context_window (80%) when not set
execution_limitsMax turns, tokens, duration
cache_configPrompt caching behavior (see Prompt Caching)
tool_executionParallel, Sequential, or Batched (see Tools)
retry_configRetry behavior for transient errors (see Retry)
before_turnCalled before each LLM call; return false to abort (see Callbacks)
after_turnCalled after each turn with messages and usage (see Callbacks)
on_errorCalled on StopReason::Error with the error string (see Callbacks)
input_filtersInput filters applied to user messages before the LLM call (see Tools)
compaction_strategyCustom compaction strategy (see Custom Compaction below)
turn_delayOptional 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:

  1. The current tool finishes normally
  2. All remaining tool calls are skipped with is_error: true and "Skipped due to queued user message"
  3. The steering message is injected into context
  4. 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:

ModeBehavior
QueueMode::OneAtATimeDelivers one message per turn (default)
QueueMode::AllDelivers 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 (steer appends to the back; delivery pops the front).
  • Delivery of a requeued batch follows the steering QueueMode: All injects it at one check, the default OneAtATime delivers 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:

EventWhen
AgentStartLoop begins
AgentEnd { messages }Loop finishes, all new messages
TurnStartNew 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>;
}
}
MethodPurpose
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>,
}
}
FieldPurpose
tool_call_idUnique ID for this tool call (for correlating events)
tool_nameName of the tool being executed
cancelCancellation token — check ctx.cancel.is_cancelled() in long-running tools
on_updateCallback for streaming partial ToolResult updates to the UI (emits ToolExecutionUpdate)
on_progressCallback 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

  1. LLM returns Content::ToolCall blocks in its response
  2. Agent loop emits ToolExecutionStart for each
  3. Tool's execute() is called with parsed arguments
  4. Result (or error) is wrapped in Message::ToolResult
  5. ToolExecutionEnd is emitted
  6. All tool results are added to context
  7. 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(&params)?;
        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_update as often as useful — there's no rate limit. The callback is synchronous and cheap.
  • Always check ctx.on_update.is_some() before building the ToolResult. If None, the loop isn't interested in updates (e.g., testing).
  • Use details for structured datacontent is for human-readable text, details can 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 _ctx to 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:

  1. 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 build can stream compiler output as it happens, so the human can interrupt early if something is wrong.

  2. Agent UIs — Tools like web dashboards, IDE extensions, or chat interfaces can render live progress bars, log tails, or status indicators. The details field in ToolResult carries 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:

StrategyBehavior
SequentialOne 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; the ToolExecutionStart event 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

ProtocolMechanism
AnthropicForced tool call — a synthetic tool is built from your schema and tool_choice forces it; the loop unwraps the call back into text
OpenAI-compatibleresponse_format: {type: "json_schema", strict: true}
Google GeminigenerationConfig.responseSchema + JSON mime type (note: Gemini uses an OpenAPI-style schema dialect — your schema is passed through as given)
OpenAI Responses / Azure / Vertex / BedrockNot yet wired — a warning is logged and the model replies as free text, which still must parse into T

Semantics & caveats

  • prompt_structured runs the loop to completion internally and returns the parsed T — 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); NoOutput when 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:

  1. Explicit with_context_config(...) → always wins
  2. Has model_config → auto-derives from context_window (80%)
  3. 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

ProviderCaching TypeSavingsFramework Action
AnthropicExplicit (cache breakpoints)90% on hits✅ Auto-placed
OpenAIAutomatic (>1024 tokens)50% on hitsNone needed
DeepSeekAutomatic prefix cacheVaries by modelNone needed
Google GeminiImplicit (automatic)VariesNone needed
Azure OpenAIAutomatic (same as OpenAI)50% on hitsNone needed
Amazon BedrockNone (no automatic caching)Not supported

What Gets Cached (Anthropic)

yoagent places up to 3 cache breakpoints automatically:

  1. System prompt — stable across all turns
  2. Tool definitions — rarely change between turns
  3. 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 metric
  • cache_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 CachingWith 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

  1. Keep system prompts stable — changing the system prompt between turns invalidates the cache
  2. Don't shuffle tools — tool order matters for cache prefix matching
  3. Let it work automatically — the default CacheStrategy::Auto is optimal for most use cases
  4. 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
  1. The agent loop calls the provider
  2. If the provider returns a retryable error:
    • If a retry-after delay was provided (rate limits), use that
    • Otherwise, calculate delay: initial_delay × multiplier^(attempt-1) with ±20% jitter
    • Wait, then retry
  3. After max_retries attempts, the error propagates normally

What gets retried

Error TypeRetried?Why
RateLimited (429)✅ YesTemporary — provider will accept requests again soon
Network✅ YesTransient — connection resets, timeouts, DNS failures
Auth (401/403)❌ NoPermanent — wrong API key won't fix itself
Api (400, etc.)❌ NoPermanent — bad request won't change on retry
Cancelled❌ NoUser-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:

  1. Metadata (~100 tokens/skill) — name + description, always in the system prompt
  2. Instructions (<5k tokens) — SKILL.md body, loaded when the agent decides the skill is relevant
  3. 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

MethodPurpose
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:

  1. A shared_state tool with get, set, list, and remove actions
  2. 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 (see examples/rlm.rs). Use with_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

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

TypeSerializeDeserializePartialEq
ContentYesYesYes
MessageYesYesYes
AgentMessageYesYesYes
ExtensionMessageYesYesYes
UsageYesYesYes
StopReasonYesYesYes
ToolResultYesYesYes
CacheConfigYesYesYes
ToolExecutionStrategyYesYesYes
ContextConfigYesYesNo
ExecutionLimitsYesYesNo

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_new verifies the agent's history still extends the session path and returns SessionError::HistoryDiverged when 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 with Session::from_messages after a divergence. The flat save_messages() / restore_messages() API remains for single-branch persistence.

API sketch

MethodPurpose
append(msg) -> idAdd 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 activityGASP events
loop startsrun.started (the goal is stamped in the run commit's Goal: trailer)
each assistant turnmodel.called / model.finished paired nodes
each tool executiontool.called / tool.finished (with success flag)
loop endsrun.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_usd is recorded when the ModelConfig has 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 CostConfig data as session_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:

  1. Connects to the MCP server and performs the initialize handshake
  2. Calls tools/list to discover available tools
  3. Wraps each MCP tool as an AgentTool via McpToolAdapter
  4. 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 failure
  • McpError::Protocol — unexpected response format
  • McpError::JsonRpc — server returned a JSON-RPC error
  • McpError::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 your Cargo.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 methodMapped 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:

  1. Substitutes path parameters in the URL (/pets/{petId}/pets/123)
  2. Adds query parameters as ?key=value
  3. Adds header parameters
  4. Applies auth from config
  5. Sends the request body as JSON (if the operation has one)
  6. 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 operationId are skipped
  • Path-level $ref items are skipped

Providers Overview

yoagent supports multiple LLM providers through the StreamProvider trait and ApiProtocol dispatch.

Supported Protocols

ProtocolProvider StructAPI Format
AnthropicMessagesAnthropicProviderAnthropic Messages API
OpenAiCompletionsOpenAiCompatProviderOpenAI Chat Completions
OpenAiResponsesOpenAiResponsesProviderOpenAI Responses API
AzureOpenAiResponsesAzureOpenAiProviderAzure OpenAI Responses
GoogleGenerativeAiGoogleProviderGoogle Gemini API
GoogleVertexGoogleVertexProviderGoogle Vertex AI
BedrockConverseStreamBedrockProviderAWS 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 ApiProtocolStreamProvider. 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

ConstructorProviderProtocolDefault Base URLContextDefault Max Output
ModelConfig::anthropic(id, name)AnthropicAnthropicMessageshttps://api.anthropic.com/v1200K16,000
ModelConfig::claude_fable_5()AnthropicAnthropicMessageshttps://api.anthropic.com/v11M64,000
ModelConfig::claude_opus_4_8()AnthropicAnthropicMessageshttps://api.anthropic.com/v11M64,000
ModelConfig::claude_sonnet_5()AnthropicAnthropicMessageshttps://api.anthropic.com/v11M64,000
ModelConfig::claude_haiku_4_5()AnthropicAnthropicMessageshttps://api.anthropic.com/v1200K32,000
ModelConfig::openai(id, name)OpenAIOpenAiCompletionshttps://api.openai.com/v1128K4,096
ModelConfig::gpt_5_5()OpenAIOpenAiCompletionshttps://api.openai.com/v11M64,000
ModelConfig::opencode_zen(model_id)OpenCode Zenby model familyhttps://opencode.ai/zen/v1128K16,000
ModelConfig::opencode_go(model_id)OpenCode Goby model familyhttps://opencode.ai/zen/go/v1128K16,000
ModelConfig::google(id, name)Google GeminiGoogleGenerativeAihttps://generativelanguage.googleapis.com1M8,192
ModelConfig::xai(id, name)xAIOpenAiCompletionshttps://api.x.ai/v1131,0724,096
ModelConfig::groq(id, name)GroqOpenAiCompletionshttps://api.groq.com/openai/v1128K4,096
ModelConfig::deepseek(id, name)DeepSeekOpenAiCompletionshttps://api.deepseek.com1M384K
ModelConfig::mistral(id, name)MistralOpenAiCompletionshttps://api.mistral.ai/v1128K4,096
ModelConfig::minimax(id, name)MiniMaxOpenAiCompletionshttps://api.minimaxi.chat/v11M4,096
ModelConfig::meta(id, name)Meta (Muse Spark) — US-only preview as of 2026-07OpenAiCompletionshttps://api.meta.ai/v11M131,072
ModelConfig::zai(id, name)Z.aiOpenAiCompletionshttps://api.z.ai/api/paas/v4128K4,096
ModelConfig::qwen(id, name)Qwen / DashScopeOpenAiCompletionshttps://dashscope-intl.aliyuncs.com/compatible-mode/v1128K4,096
ModelConfig::ollama(base_url, model_id)OllamaOpenAiCompletionscaller provided128K4,096
ModelConfig::openai_compat(base_url, model_id, provider, compat)Custom compatible serverOpenAiCompletionscaller provided128K4,096
ModelConfig::local(base_url, model_id)Local compatible serverOpenAiCompletionscaller provided128K4,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, not max_completion_tokens
  • thinking: { "type": "enabled" | "disabled" }
  • reasoning_effort when ThinkingLevel is not Off
  • 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 stats
  • content_block_start — Text, thinking, or tool_use block
  • content_block_delta — Text, thinking, input JSON, or signature deltas
  • content_block_stop — Block complete
  • message_delta — Stop reason, output usage
  • message_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:

LevelEffort
Minimal, Lowlow
Mediummedium
Highhigh

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_control on its last content block, creating a cache breakpoint

This means on repeated calls, only the latest message is processed at full price.

Configuration

SettingValue
API URL{base_url}/messages (default https://api.anthropic.com/v1/messages)
API Version2023-06-01
Auth Headerx-api-key (or Authorization: Bearer with AnthropicCompat { bearer_auth: true } / a custom authorization header in ModelConfig.headers)
Default Max Tokensrequest 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

VariablePurpose
ANTHROPIC_API_KEYAPI 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

ProviderConstructorKey Differences
OpenAIOpenAiCompat::openai()developer role, max_completion_tokens, store, reasoning_effort
xAI (Grok)OpenAiCompat::xai()reasoning field for thinking (not reasoning_content)
GroqOpenAiCompat::groq()Standard defaults
CerebrasOpenAiCompat::cerebras()Standard defaults
OpenRouterOpenAiCompat::openrouter()max_completion_tokens
MistralOpenAiCompat::mistral()max_tokens field
DeepSeekOpenAiCompat::deepseek()max_tokens, thinking, reasoning_effort, 1M context window
MiniMaxOpenAiCompat::minimax()Standard defaults, 1M context window
Z.ai (Zhipu)OpenAiCompat::zai()Standard defaults
QwenOpenAiCompat::qwen()Qwen reasoning content format, max_tokens, streaming usage
OllamaOpenAiCompat::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

  1. 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()
        }
    }
}
}
  1. Create a ModelConfig that 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 — Uses reasoning_content field (DeepSeek, default)
  • ThinkingFormat::Xai — Uses reasoning field (Grok)
  • ThinkingFormat::Qwen — Uses reasoning_content field (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.com is 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:

yoagentGoogle API
user roleuser role
assistant rolemodel role
Content::Text{"text": "..."}
Content::Image{"inlineData": {...}}
Content::ToolCall{"functionCall": {...}}
Message::ToolResult{"functionResponse": {...}}
System promptsystemInstruction field
Toolstools[].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:

yoagentBedrock API
Content::Text{"text": "..."}
Content::Image{"image": {"format": "...", "source": {"bytes": "..."}}}
Content::ToolCall{"toolUse": {"toolUseId": "...", "name": "...", "input": ...}}
Message::ToolResult{"toolResult": {"toolUseId": "...", "content": [...], "status": "success"}}
System promptsystem array of text blocks
ToolstoolConfig.tools[].toolSpec
Max tokensinferenceConfig.maxTokens

Stream Events

Bedrock's ConverseStream returns these event types:

  • contentBlockStart — New content block (text or tool use)
  • contentBlockDelta — Text or tool use input delta
  • contentBlockStop — Block complete
  • messageStop — 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 content
    • response.function_call_arguments.start — Tool call start
    • response.function_call_arguments.delta — Tool call arguments
    • response.completed — Final usage data

Message Format

Uses the Responses API input format:

yoagentAzure 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 promptinstructions 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:

GatewayModel familyProtocolPair with
Zengpt-*OpenAI ResponsesOpenAiResponsesProvider
Zenclaude-*, qwen*Anthropic MessagesAnthropicProvider
ZenDeepSeek, MiniMax, GLM, Kimi, ...Chat CompletionsOpenAiCompatProvider
Goqwen*, minimax-*Anthropic MessagesAnthropicProvider
GoGLM, Kimi, DeepSeek, MiMo, ...Chat CompletionsOpenAiCompatProvider

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)
ActionDescription
getReturns the value for a key, or error if not found
setStores a value, returns confirmation with byte size
listLists all keys with their byte sizes
removeDeletes 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"));
}
SignatureDescription
Agent::from_config(config: ModelConfig) -> SelfBuild 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) -> SelfBuild 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

MethodDescription
with_system_prompt(prompt) -> SelfSet the system prompt
with_api_key(key) -> SelfOverride the env-resolved API key
with_thinking(level: ThinkingLevel) -> SelfSet thinking level (Off, Minimal, Low, Medium, High)
with_max_tokens(max: u32) -> SelfSet max output tokens

Tools & Integrations

MethodDescription
with_tools(tools: Vec<Box<dyn AgentTool>>) -> SelfSet tools (replaces existing)
with_sub_agent(sub: SubAgentTool) -> SelfAdd a sub-agent tool
with_skills(skills: SkillSet) -> SelfLoad 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

MethodDescription
with_context_config(config: ContextConfig) -> SelfSet context compaction config
with_execution_limits(limits: ExecutionLimits) -> SelfSet execution limits (max turns, tokens, duration)
with_compaction_strategy(strategy: impl CompactionStrategy) -> SelfSet a custom compaction strategy
without_context_management() -> SelfDisable automatic context compaction and execution limits

Behavior

MethodDescription
with_messages(msgs: Vec<AgentMessage>) -> SelfPre-load message history
with_cache_config(config: CacheConfig) -> SelfSet prompt caching configuration
with_tool_execution(strategy: ToolExecutionStrategy) -> SelfSet tool execution strategy (Parallel, Sequential, Batched)
with_retry_config(config: RetryConfig) -> SelfSet retry configuration
with_input_filter(filter: impl InputFilter) -> SelfAdd an input filter (runs on user messages before LLM call)

Callbacks

MethodDescription
on_before_turn(f: Fn(&[AgentMessage], usize) -> bool) -> SelfCalled before each LLM call; return false to abort
on_after_turn(f: Fn(&[AgentMessage], &Usage)) -> SelfCalled after each LLM response and tool execution
on_error(f: Fn(&str)) -> SelfCalled when the LLM returns StopReason::Error

Prompting

MethodDescription
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

MethodDescription
messages() -> &[AgentMessage]Get the full message history
is_streaming() -> boolWhether the agent is currently running

State Mutation

MethodDescription
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

MethodDescription
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() -> usizeNumber of pending steering messages
follow_up_queue_len() -> usizeNumber 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

MethodDescription
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.

MethodDescription
with_description(desc) -> SelfWhat the parent LLM sees (helps it decide when to delegate)
with_system_prompt(prompt) -> SelfThe sub-agent's own instructions
with_api_key(key) -> SelfOverride the env-resolved API key
with_tools(tools: Vec<Arc<dyn AgentTool>>) -> SelfTools available to the sub-agent
with_shared_state(state: SharedState) -> SelfAttach a shared key-value store (injects shared_state tool automatically)
with_max_turns(N) -> SelfTurn limit (default: 10)
with_thinking(level: ThinkingLevel) -> SelfEnable extended thinking
with_max_tokens(max: u32) -> SelfSet max output tokens
with_cache_config(config: CacheConfig) -> SelfPrompt caching settings
with_tool_execution(strategy: ToolExecutionStrategy) -> SelfTool execution strategy (Parallel, Sequential, Batched)
with_retry_config(config: RetryConfig) -> SelfCustom retry configuration
with_turn_delay(delay: Duration) -> SelfInter-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

MethodDescription
async get(key) -> Option<String>Read a value by key
async set(key, value) -> Result<(), SharedStateError>Store a value
async remove(key) -> boolDelete a key, returns whether it existed
async keys() -> Vec<String>List all keys
async summary() -> StringHuman-readable summary of keys and sizes

Built-in Backends

BackendDescription
MemoryBackendIn-memory HashMap with byte capacity limit (default)
FileBackendOne 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 itself
  • AgentTool trait — interface tools must implement
  • StreamProvider trait — interface providers must implement
  • AgentMessage, AgentEvent, StreamDelta — message & event types
  • AgentContext — 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
  • Agent struct — 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:

  • Orchestrator struct — 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

  1. Implement StreamProvider trait (Layer 1 interface)
  2. Register with ProviderRegistry under an ApiProtocol (Layer 2)
  3. Set ModelConfig.api to match that protocol
  4. 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

  1. Implement AgentTool trait (Layer 1 interface)
  2. Add to the tools vec (via default_tools() or custom)
  3. The agent loop converts tools to ToolDefinition (name, description, schema) for the LLM
  4. When the LLM returns Content::ToolCall, the loop finds the matching tool and calls execute()
  5. Results are wrapped in Message::ToolResult and 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.