Configuration

AgentLoopConfig

The main configuration for the agent loop. Build it with AgentLoopConfig::new(provider, model), which sets every other field to its default, then set the fields you need. The struct is #[non_exhaustive]: it cannot be built as a struct literal outside the crate, so new fields can be added without breaking callers.

#![allow(unused)]
fn main() {
#[non_exhaustive]
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_output_sink: Option<SharedState>,
    pub tool_execution: ToolExecutionStrategy,
    pub tool_middleware: Vec<Arc<dyn ToolMiddleware>>,
    pub output_schema: Option<OutputSchema>,
    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>,
    pub extensions: Vec<Arc<dyn Extension>>,
    pub tree_extensions: Vec<Arc<dyn Extension>>,
    pub max_stop_continues: usize,
    pub run_label: Option<String>,
}
}

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,
    pub output_schema: Option<OutputSchema>,
}
}

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: 200
    pub tool_output_max_lines_overrides: HashMap<String, usize>, // Default: {"read_file": MAX, "shared_state": MAX}
    pub compact_target_ratio: f32,                            // Default: 0.7
    pub compact_headroom_turns: Option<usize>,                // Default: Some(30)
    pub truncate_tool_output_on_append: bool,                 // Default: true
}
}

compact_headroom_turns sets the compaction target from observed growth (target = budget − turns × growth_per_turn), keeping the interval between compactions constant as a session lengthens; compact_target_ratio is the fallback and a ceiling on retention. truncate_tool_output_on_append caps tool output as it enters the context rather than retroactively, and tool_output_max_lines_overrides gives per-tool budgets so a tool that head+tail would damage (a paging reader) can opt out. All three exist to keep the provider's prefix cache intact — see Context Management.

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() {
#[non_exhaustive]                      // build with Default::default() + with_*
pub struct ExecutionLimits {
    pub max_turns: usize,              // Default: 50
    pub max_total_tokens: usize,       // Default: 1,000,000
    pub max_duration: Duration,        // Default: 600s
    pub max_consecutive_identical_tool_calls: Option<usize>,  // Default: Some(3)
}
}
#![allow(unused)]
fn main() {
ExecutionLimits::default()
    .with_max_turns(20)
    .with_max_consecutive_identical_tool_calls(None)  // disable loop detection
}

ThinkingLevel

#![allow(unused)]
fn main() {
#[non_exhaustive]  // match with a wildcard arm
pub enum ThinkingLevel {
    Off,        // Request no thinking: the field is omitted (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
    XHigh,      // Anthropic: effort "xhigh" / 16,384 (serde: "xhigh")
    Max,        // Anthropic: effort "max" / 30,720
}
}

Where a provider's ladder is shorter, the upper levels are clamped to the highest value this crate knows the provider accepts, rather than sent as a value it would reject — except Anthropic's adaptive effort, which is passed through unclamped: Opus 4.6 / Sonnet 4.6 have no xhigh rung and reject it.

LevelAnthropic effortAnthropic legacy / Bedrock budgetOpenAI-compat¹ / Responses / Azure effort, by ceiling²DeepSeek effort³Gemini 2.x / Vertex budget⁴
Off(omitted)(omitted)(omitted)(omitted); thinking: disabled(omitted)
Minimal, Lowlow1,024lowlow1,024
Mediummedium2,048mediummedium8,192
Highhigh8,192highhigh24,576
XHighxhigh16,384High: high (clamped) · XHigh/Max: xhighhigh (clamped)24,576 (clamped)
Maxmax30,720High: high (clamped) · XHigh: xhigh (clamped) · Max: maxmax24,576 (clamped)

Off sends nothing, and that is not always "no thinking". No provider receives a thinking or effort field for Off (DeepSeek's explicit thinking: disabled is the one exception). A model that thinks regardless then runs at its own default: Claude Opus 5.5 and Fable 5.1 always think, and Opus 5 thinks whenever the field is absent; an OpenAI reasoning model runs at its default effort (medium); Grok cannot disable reasoning (default high); Gemini 3 thinks at its default level. The crate never sends OpenAI's none rung, which several models reject with HTTP 400.

¹ OpenAI-compat sends reasoning_effort only when supports_reasoning_effort is set; the Responses and Azure providers always send reasoning.effort except for Off.

² OpenAiCompat::max_reasoning_effort (a ReasoningEffortCeiling, default High) is declared per model — the Responses and Azure providers read it from ModelConfig::compat too. Presets: gpt_6_astra / gpt_6_sol / gpt_6_1_sol / gpt_6_luna — Max; gpt_5_5 — XHigh; OpenAiCompat::xai() — XHigh (Grok treats xhigh as high where it lacks the rung); everything else High. A clamp is logged once per process with tracing::warn!.

³ The DeepSeek column applies when both supports_thinking_control and supports_reasoning_effort are set. DeepSeek maps a requested xhigh to high itself, so XHigh is sent as high and only Max selects max.

⁴ Gemini 3 and later get thinkingConfig.thinkingLevel instead, never both: Minimal → MINIMAL, Low → LOW, Medium → MEDIUM, High / XHigh / Max → HIGH. Minimal is clamped to LOW on models without a MINIMAL rung (3.7 / 3.8 Flash, 3.x Pro, unlisted ids); it, not Off, is how to ask a Gemini 3 model for the least thinking. Image models take only the levels they list (3.1 Flash / Flash-Lite Image: MINIMAL or HIGH; 3 Pro Image: HIGH), and Gemini 3 TTS models get no thinkingConfig. The generation is read from the model id; override it with GoogleCompat. See Google Gemini.

CostConfig

Token pricing per million:

#![allow(unused)]
fn main() {
#[non_exhaustive]                          // build with new() + with_*, not a literal
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,
    pub context_tiers: Vec<ContextTier>,   // empty = one flat rate at every size
}
}

Cache rates are set with builders rather than positionally. Four same-typed f64 arguments in a row is a transposition hazard, and no vendor publishes them in one order — Anthropic lists input / cache-write / cache-read / output, OpenAI lists input / cached-input / output:

#![allow(unused)]
fn main() {
CostConfig::new(5.0, 30.0)          // input, output — output is always dearer
    .with_cache_read(0.5)
    .with_cache_write(6.25)
}

A cache rate left at 0.0 bills at the input rate. Unset cache_read_per_million / cache_write_per_million mean "not separately priced", so cost_usd bills those tokens at the applicable input rate — the base input_per_million, or inside a context tier that tier's own input_per_million (never the base cache rate). A vendor with no separate cache-write charge is priced correctly without setting one, and a forgotten cache rate can no longer bill cached tokens as free. A fully zero config still costs $0. There is no way to say "cache reads are free but input is not"; set a tiny positive rate if a vendor ever charges that.

ModelConfig::cost is an Option<CostConfig>, filled at construction from the process-wide price table — empty until the process opts in with prices::enable_bundled() (the bundled src/provider/prices.json) or prices::enable_live(..); see Model Pricing. None means pricing unknown, not free. Once opted in, the named presets (claude_*, gpt_5_5, gpt_6_astra / gpt_6_sol / gpt_6_1_sol / gpt_6_luna, gemini_4_argon, gemini_3_8_flash, grok_4_7, mistral_large_4, glm_5_3 / glm_5_3_flash, qwen_3_8_max / qwen_3_8_flash) are all priced; the generic first-party constructors (anthropic, openai, openai_responses, google, xai, groq, deepseek, mistral, zai, minimax, qwen, meta) are priced when the table lists the id — so ModelConfig::openai("gpt-5.5", ..) carries gpt_5_5()'s rates — and None otherwise; gateways and custom endpoints (custom, openai_compat, local, ollama, opencode_zen / opencode_go) are always None. Supply a price when you know it:

#![allow(unused)]
fn main() {
let mut config = ModelConfig::deepseek("deepseek-flash", "DeepSeek Flash");
// DeepSeek's peak rates (off-peak is half); cache writes are not a DeepSeek
// category, so none is set.
config.cost = Some(CostConfig::new(0.30, 1.20).with_cache_read(0.006));
}

Some with every rate zero means free — a model you run locally, say — and the crate's accounting reports 0.0 for it. session_cost_usd(), SessionStats::cost_usd and the llm_stream span's cost_usd are absent only for cost: None, never $0 for an unknown price:

#![allow(unused)]
fn main() {
let mut config = ModelConfig::local("http://localhost:1234/v1", "qwen3");
config.cost = Some(CostConfig::new(0.0, 0.0)); // free, not unknown
}

To re-price from a table of your own (a negotiated rate, a price change yoagent has not released yet), use config.with_prices(&table) for one config, or prices::global::install_override(table) / YOAGENT_PRICES=path for every config built afterwards (config.reprice() updates one built earlier); see Model Pricing.

To adjust one rate on a priced config, use get_or_insert_with rather than if let Some(c) = config.cost.as_mut(), which silently does nothing on an unpriced one:

#![allow(unused)]
fn main() {
config.cost.get_or_insert_with(CostConfig::default).input_per_million = 1.80;
}

On an unpriced config (cost: None) that line creates a CostConfig whose other rates are zero: output tokens would be billed at a real $0, and cache tokens at the input rate rather than their own. Price an unpriced config with every rate you pay instead:

#![allow(unused)]
fn main() {
let mut config = ModelConfig::deepseek("deepseek-flash", "DeepSeek Flash");
// Rates per million tokens; take them from the vendor's pricing page.
config.cost = Some(CostConfig::new(INPUT, OUTPUT).with_cache_read(CACHE_READ));
}

Persistence caveat. Before 0.19, unpriced configs were written with an all-zero cost object. To keep those loading as unknown rather than free, a cost object whose rates are all zero (is_configured() false, context tiers included) deserializes to None. A free config saved by 0.19 has the same encoding, so it too reloads as None; set the zero CostConfig again after loading if you persist one.

Context tiers

Some vendors charge more above a prompt-size threshold. cost_usd selects by the request's prompt tokens (input + cache_read + cache_write), so a long reply to a short prompt stays on the base rate:

#![allow(unused)]
fn main() {
CostConfig::new(5.0, 30.0)
    .with_context_tier(ContextTier::new(272_000, 10.0, 45.0).with_cache_read(1.0))
}

Tiers are kept sorted, and cost_usd takes the last one the prompt clears, so a multi-step schedule works. The whole request moves to the tier's rates. The OpenAI presets gpt_5_5, gpt_6_astra, gpt_6_sol, gpt_6_1_sol and gpt_6_luna set one at 272,000 prompt tokens (exclusive); gpt_5_5's long-band cache-read rate is unverified — see its rustdoc. claude_haiku_5_5 sets one at 100,000 and grok_4_7 at 200,000. The other Anthropic presets and the Meta presets are flat. A tier's cache rate left at 0.0 bills at that tier's input rate.

One caveat if you add a tier: prompt size is derived as input + cache_read + cache_write, which holds only where the provider subtracts cached tokens out of input. Bedrock does: bedrock.rs fills input from inputTokens and the cache fields from cacheReadInputTokens / cacheWriteInputTokens, and AWS's prompt caching guide states that "the inputTokens field represents only the non-cached input tokens" and "total input tokens = inputTokens + cacheReadInputTokens + cacheWriteInputTokens".

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.