Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,12 @@ env:

jobs:
check:
name: Check & Test
runs-on: ubuntu-latest
name: Check & Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4

Expand All @@ -34,3 +38,24 @@ jobs:

- name: Build
run: cargo build --all-features

# Built-in tools shell out to unix commands, so Windows only checks compilation.
windows-check:
name: Check (windows)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Check
run: cargo check --all-targets --all-features

msrv:
name: MSRV (1.86)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.86
- uses: Swatinem/rust-cache@v2
- name: Check
run: cargo check --all-features
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ The loop: stream assistant response → extract tool calls → execute tools (pa

### Provider System

7 provider implementations behind `StreamProvider`, dispatched by `ApiProtocol` enum via `ProviderRegistry`:
7 provider implementations behind `StreamProvider`. `Agent` holds a concrete provider directly; `ProviderRegistry` maps `ApiProtocol` → provider for registry-based dispatch (e.g. custom multi-protocol routers):

| Protocol | File | Covers |
|----------|------|--------|
Expand All @@ -61,7 +61,7 @@ The loop: stream assistant response → extract tool calls → execute tools (pa

### Context Management (`context.rs`)

- **`ContextTracker`** — hybrid real-usage + estimation for token tracking
- **`ContextTracker`** — hybrid real-usage + estimation; the loop uses it to calibrate the compaction budget against real provider usage
- **`compact_messages()`** — tiered compaction: Level 1 (truncate tool outputs) → Level 2 (summarize old turns) → Level 3 (drop middle turns)
- **`ExecutionLimits`/`ExecutionTracker`** — max turns (50), max tokens (1M), max duration (10 min)

Expand Down
10 changes: 5 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ description = "Simple, effective agent loop with tool execution and event stream
license = "MIT"
repository = "https://github.com/yologdev/yoagent"
homepage = "https://yologdev.github.io/yoagent/"
documentation = "https://yologdev.github.io/yoagent/"
documentation = "https://docs.rs/yoagent"
readme = "README.md"
keywords = ["agent", "llm", "ai", "tools", "streaming"]
categories = ["api-bindings", "asynchronous"]
authors = ["Yuanhao <yuanhao@yolog.dev>"]
rust-version = "1.85"
rust-version = "1.86"
exclude = ["docs/images/*", "docs/theme/*", ".github/*", "scripts/*"]

[dependencies]
tokio = { version = "1", features = ["full"] }
tokio = { version = "1", features = ["rt", "sync", "fs", "macros", "process", "time", "io-util"] }
tokio-util = "0.7"
tokio-stream = "0.1"
serde = { version = "1", features = ["derive"] }
Expand All @@ -30,10 +30,10 @@ futures = "0.3"
rand = "0.10.0"
base64 = "0.22"
openapiv3 = { version = "2", optional = true }
serde_yaml = { version = "0.9", optional = true }
serde_yaml_ng = { version = "0.10", optional = true }

[features]
openapi = ["dep:openapiv3", "dep:serde_yaml", "reqwest/query"]
openapi = ["dep:openapiv3", "dep:serde_yaml_ng", "reqwest/query"]

[dev-dependencies]
tokio = { version = "1", features = ["full", "test-util"] }
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@ Everything is observable via events. Supports 7 API protocols covering 20+ LLM p
- [AgentSkills](https://agentskills.io)-compatible skills — load skill directories, inject into system prompt, agent activates on demand

**Multi-Provider**
- 7 API protocols, 20+ providers through a modular registry
- 7 API protocols, 20+ providers behind one `StreamProvider` trait
- One OpenAI-compatible implementation covers OpenAI, xAI, Groq, Cerebras, OpenRouter, Mistral, and more
- Per-provider quirk flags (`OpenAiCompat`) handle auth, reasoning format, and tool handling differences
- Per-provider quirk flags (`OpenAiCompat`, `AnthropicCompat`) handle auth, reasoning format, and tool handling differences
- Capability notes: thinking/reasoning controls are wired for Anthropic and for OpenAI-compatible providers whose `ModelConfig` opts in (the `openai()`/`deepseek()` presets do; other compat presets silently drop `thinking_level`). Gemini, Vertex, Bedrock, and Azure are planned — setting `thinking_level` there logs a warning. Client-side prompt-cache breakpoints are Anthropic-specific; most other providers cache server-side automatically, but Bedrock has no automatic caching

**Built-in Tools**
- `bash` — Shell execution with timeout, output truncation, command deny patterns
Expand Down Expand Up @@ -82,7 +83,7 @@ Or add to `Cargo.toml`:

```toml
[dependencies]
yoagent = "0.6"
yoagent = "0.9"
tokio = { version = "1", features = ["full"] }
```

Expand Down
111 changes: 110 additions & 1 deletion src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@ impl Agent {
self
}

/// Set the sampling temperature. Note: the newest reasoning models
/// (e.g. Claude Fable 5 / Opus 4.7+) reject sampling parameters — leave
/// unset for those.
pub fn with_temperature(mut self, temperature: f32) -> Self {
self.temperature = Some(temperature);
self
}

pub fn with_context_config(mut self, config: ContextConfig) -> Self {
self.context_config = Some(config);
self
Expand Down Expand Up @@ -476,6 +484,15 @@ impl Agent {
/// Call [`finish()`](Self::finish) after draining the receiver to restore
/// agent state (messages, tools). `finish()` is also called automatically
/// at the start of the next `prompt` / `continue_loop` call.
///
/// # Panics
///
/// Panics if the agent still counts as streaming — in practice only when
/// a previous `*_with_sender` future was dropped mid-run, which leaves
/// the agent stuck in the streaming state ([`Agent::finish`] cannot
/// recover it; recreate the agent). Runs started by the
/// receiver-returning methods are joined automatically. A
/// misuse-`Result` variant is planned for 0.10.
pub async fn prompt(&mut self, text: impl Into<String>) -> mpsc::UnboundedReceiver<AgentEvent> {
let msg = AgentMessage::Llm(Message::user(text));
self.prompt_messages(vec![msg]).await
Expand All @@ -485,6 +502,15 @@ impl Agent {
/// agent loop running concurrently for true streaming.
///
/// Call [`finish()`](Self::finish) after draining events to restore state.
///
/// # Panics
///
/// Panics if the agent still counts as streaming — in practice only when
/// a previous `*_with_sender` future was dropped mid-run, which leaves
/// the agent stuck in the streaming state ([`Agent::finish`] cannot
/// recover it; recreate the agent). Runs started by the
/// receiver-returning methods are joined automatically. A
/// misuse-`Result` variant is planned for 0.10.
pub async fn prompt_messages(
&mut self,
messages: Vec<AgentMessage>,
Expand Down Expand Up @@ -539,6 +565,15 @@ impl Agent {
/// agent.prompt_with_sender("hello", tx).await;
/// # }
/// ```
///
/// # Panics
///
/// Panics if the agent still counts as streaming — in practice only when
/// a previous `*_with_sender` future was dropped mid-run, which leaves
/// the agent stuck in the streaming state ([`Agent::finish`] cannot
/// recover it; recreate the agent). Runs started by the
/// receiver-returning methods are joined automatically. A
/// misuse-`Result` variant is planned for 0.10.
pub async fn prompt_with_sender(
&mut self,
text: impl Into<String>,
Expand All @@ -550,6 +585,15 @@ impl Agent {

/// Send messages as a prompt, streaming events to a caller-provided sender.
/// Blocks until the loop finishes and state is restored.
///
/// # Panics
///
/// Panics if the agent still counts as streaming — in practice only when
/// a previous `*_with_sender` future was dropped mid-run, which leaves
/// the agent stuck in the streaming state ([`Agent::finish`] cannot
/// recover it; recreate the agent). Runs started by the
/// receiver-returning methods are joined automatically. A
/// misuse-`Result` variant is planned for 0.10.
pub async fn prompt_messages_with_sender(
&mut self,
messages: Vec<AgentMessage>,
Expand Down Expand Up @@ -587,6 +631,13 @@ impl Agent {
/// receiver immediately with the loop running concurrently.
///
/// Call [`finish()`](Self::finish) after draining events to restore state.
///
/// # Panics
///
/// Panics if there are no messages to continue from, or if a previous
/// `*_with_sender` future was dropped mid-run (the agent is stuck in the
/// streaming state; [`Agent::finish`] cannot recover it — recreate the
/// agent). A misuse-`Result` variant is planned for 0.10.
pub async fn continue_loop(&mut self) -> mpsc::UnboundedReceiver<AgentEvent> {
self.finish().await; // restore from previous if needed

Expand Down Expand Up @@ -618,6 +669,13 @@ impl Agent {

/// Continue from current context, streaming events to a caller-provided sender.
/// Blocks until the loop finishes and state is restored.
///
/// # Panics
///
/// Panics if there are no messages to continue from, or if a previous
/// `*_with_sender` future was dropped mid-run (the agent is stuck in the
/// streaming state; [`Agent::finish`] cannot recover it — recreate the
/// agent). A misuse-`Result` variant is planned for 0.10.
pub async fn continue_loop_with_sender(&mut self, tx: mpsc::UnboundedSender<AgentEvent>) {
self.finish().await; // restore from previous if needed

Expand Down Expand Up @@ -674,7 +732,58 @@ impl Agent {

// -- Internal --

/// The explicit key if set, else the provider-conventional env var
/// (see [`crate::provider::resolve_api_key`]).
fn resolved_api_key(&self) -> String {
if !self.api_key.is_empty() {
return self.api_key.clone();
}
let provider = self
.model_config
.as_ref()
.map(|m| m.provider.as_str())
.unwrap_or("anthropic");
crate::provider::resolve_api_key_or_warn(provider)
}

/// Total dollar cost of the assistant turns currently in history, using
/// the model's [`CostConfig`](crate::provider::CostConfig) rates.
///
/// Returns `None` when no `ModelConfig` is set or when the config's
/// rates are all zero (pricing unknown — e.g. custom or local models),
/// so `None` means "can't price this", never "free". Rates come from the
/// *current* model config; sessions that switched models mid-way are
/// priced entirely at the current rates.
pub fn session_cost_usd(&self) -> Option<f64> {
let cost = &self.model_config.as_ref()?.cost;
if !cost.is_configured() {
return None;
}
Some(
self.messages
.iter()
.filter_map(|m| match m {
AgentMessage::Llm(Message::Assistant { usage, .. }) => {
Some(cost.cost_usd(usage))
}
_ => None,
})
.sum(),
)
}

fn build_config(&self) -> AgentLoopConfig {
if self.thinking_level != ThinkingLevel::Off {
if let Some(mc) = &self.model_config {
if !mc.reasoning {
tracing::warn!(
"thinking_level is set but model '{}' is not marked \
reasoning-capable (ModelConfig.reasoning = false)",
mc.id
);
}
}
}
let steering_queue = self.steering_queue.clone();
let steering_mode = self.steering_mode;

Expand All @@ -684,7 +793,7 @@ impl Agent {
AgentLoopConfig {
provider: self.provider.clone(),
model: self.model.clone(),
api_key: self.api_key.clone(),
api_key: self.resolved_api_key(),
thinking_level: self.thinking_level,
max_tokens: self.max_tokens,
temperature: self.temperature,
Expand Down
56 changes: 53 additions & 3 deletions src/agent_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
//! Both return a stream of `AgentEvent`s.

use crate::context::{
self, CompactionStrategy, ContextConfig, DefaultCompaction, ExecutionLimits, ExecutionTracker,
self, CompactionStrategy, ContextConfig, ContextTracker, DefaultCompaction, ExecutionLimits,
ExecutionTracker,
};
use crate::provider::{ModelConfig, StreamConfig, StreamEvent, StreamProvider, ToolDefinition};
use crate::types::*;
Expand Down Expand Up @@ -253,6 +254,8 @@ async fn run_loop(
) {
let mut first_turn = true;
let mut turn_number: usize = 0;
// Blends real provider usage with estimation for compaction sizing.
let mut context_tracker = ContextTracker::new();
let mut tracker = config
.execution_limits
.as_ref()
Expand Down Expand Up @@ -342,14 +345,55 @@ async fn run_loop(

turn_number += 1;

// Compact context if configured (tiered: tool outputs → summarize → drop)
// Compact context if configured (tiered: tool outputs → summarize → drop).
//
// Calibration: the tracker's hybrid figure is anchored on provider
// usage, which counts the FULL request (system prompt + tool
// schemas + any estimation shortfall on messages), while
// `total_tokens` counts messages only. The difference is a
// measured overhead; subtracting it from the budget makes the
// strategy's message-token checks equivalent to "true window
// occupancy <= max_context_tokens". The static
// `system_prompt_tokens` reserve is zeroed in the calibrated
// config because the measured overhead already includes the real
// system prompt. A floor of 10% of the configured budget
// guarantees a mis-measured overhead can never wipe the history.
if let Some(ref ctx_config) = config.context_config {
let estimated = context::total_tokens(&context.messages);
let hybrid = context_tracker.estimate_context_tokens(&context.messages);
let overhead = hybrid.saturating_sub(estimated);
let calibrated;
let effective_config = if overhead > 0 {
let floor = ctx_config.max_context_tokens / 10;
calibrated = ContextConfig {
max_context_tokens: ctx_config
.max_context_tokens
.saturating_sub(overhead)
.max(floor),
system_prompt_tokens: 0,
..ctx_config.clone()
};
tracing::debug!(
"compaction budget calibrated: {} -> {} (measured overhead: {} tokens)",
ctx_config.max_context_tokens,
calibrated.max_context_tokens,
overhead
);
&calibrated
} else {
ctx_config
};
let strategy: &dyn CompactionStrategy = config
.compaction_strategy
.as_deref()
.unwrap_or(&DefaultCompaction);
let before_len = context.messages.len();
context.messages =
strategy.compact(std::mem::take(&mut context.messages), ctx_config);
strategy.compact(std::mem::take(&mut context.messages), effective_config);
if context.messages.len() != before_len {
// Messages shifted; re-baseline from the next real usage.
context_tracker.reset();
}
}

// Stream assistant response
Expand All @@ -358,6 +402,9 @@ async fn run_loop(
let agent_msg: AgentMessage = message.clone().into();
context.messages.push(agent_msg.clone());
new_messages.push(agent_msg.clone());
if let Message::Assistant { usage, .. } = &message {
context_tracker.record_usage(usage, context.messages.len() - 1);
}

// Check for error/abort
if let Message::Assistant {
Expand Down Expand Up @@ -636,8 +683,11 @@ async fn stream_assistant_response(
// Abort forwarder to prevent forwarding events from failed attempt
forward_handle.abort();
attempt += 1;
// Server-provided Retry-After wins over backoff, but is
// clamped to max_delay_ms so a bad header can't stall the loop.
let delay = e
.retry_after()
.map(|d| d.min(std::time::Duration::from_millis(retry.max_delay_ms)))
.unwrap_or_else(|| retry.delay_for_attempt(attempt));
crate::retry::log_retry(attempt, retry.max_retries, &delay, e);
tokio::time::sleep(delay).await;
Expand Down
Loading
Loading