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
82 changes: 82 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Changelog

All notable changes to `yoagent` are documented here. The format loosely
follows [Keep a Changelog](https://keepachangelog.com/), and the project
adheres to [Semantic Versioning](https://semver.org/).

## 0.10.0

The headline change is a **config-first construction API**. You now build an
agent from a single `ModelConfig` — the provider, model id, context window, and
pricing all come from one place, and the API key is resolved from the
provider-conventional environment variable.

### Added

- `Agent::from_config(ModelConfig)` — the new primary constructor. Selects the
built-in provider for `config.api` and resolves the API key from the
provider-conventional env var (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
`XAI_API_KEY`, …; see `provider::resolve_api_key`).
- `Agent::from_provider(provider, ModelConfig)` — explicit provider (custom
`StreamProvider` impls and test doubles). Pair with `ModelConfig::mock()`.
- `Agent::from_config_with(&registry, ModelConfig) -> Result<Agent, AgentBuildError>`
— resolve the provider from a caller-supplied `ProviderRegistry`.
- `Agent::set_model(ModelConfig)` — switch model mid-session. Re-resolves the
env key; re-selects the provider only when it was registry-resolved (an
explicitly-supplied provider is never silently replaced).
- `SubAgentTool::from_config`, `from_config_with`, and `from_provider` mirror
the above.
- `ModelConfig::mock()` — a throwaway config for tests (use only with
`from_provider`).
- `AgentBuildError` (exported) — the error type for the fallible
`from_config_with` path.
- `ProviderRegistry::resolve(&ApiProtocol) -> Option<Arc<dyn StreamProvider>>`
and `StreamProvider::protocol() -> Option<ApiProtocol>`.
- Automatic env-var API-key resolution and a `with_temperature()` builder
(from the 0.9.x adoption-funnel work, now the default construction path).

### Deprecated

The following are deprecated since 0.10.0 and will be **removed in 1.0**. They
still work; you'll get a compiler warning pointing at the replacement:

- `Agent::new`, `Agent::with_model`, `Agent::with_model_config`
- `SubAgentTool::new`, `SubAgentTool::with_model`, `SubAgentTool::with_model_config`

### Migration

The old builder made you pair a provider with a matching config by hand and
pass the model id twice. The new one takes a single config:

```rust
// before (0.9): provider and config paired manually; model id passed twice
let agent = Agent::new(OpenAiCompatProvider)
.with_model_config(ModelConfig::zai("glm-4.7", "GLM 4.7"))
.with_model("glm-4.7")
.with_api_key(key);

// after (0.10): provider inferred from config.api; key from ZAI_API_KEY
let agent = Agent::from_config(ModelConfig::zai("glm-4.7", "GLM 4.7"));
```

Per constructor:

| Before | After |
|---|---|
| `Agent::new(AnthropicProvider).with_model("m").with_api_key(k)` | `Agent::from_config(ModelConfig::anthropic("m", "Name")).with_api_key(k)` (drop `with_api_key` to use `ANTHROPIC_API_KEY`) |
| `Agent::new(P).with_model_config(cfg).with_model(cfg.id)` | `Agent::from_config(cfg)` |
| `Agent::new(customProvider).with_model("m")` | `Agent::from_provider(customProvider, cfg)` |
| `Agent::new(MockProvider::text("hi")).with_model("mock")` | `Agent::from_provider(MockProvider::text("hi"), ModelConfig::mock())` |
| `SubAgentTool::new(name, provider).with_model_config(cfg)` | `SubAgentTool::from_config(name, cfg)` or `from_provider(name, provider, cfg)` |

`with_api_key` is **not** deprecated — keep it wherever you want to pass a key
explicitly instead of via the environment.

### Fixed

- Google/Vertex usage no longer double-counts cached tokens.
- `Retry-After` is clamped to `max_delay_ms`.
- Compaction budget calibration subtracts measured overhead instead of scaling
by a ratio (the old formula could collapse the budget toward zero).
- `session_cost_usd()` returns `None` for unpriced models instead of `0.0`.
- Missing API keys now log a warning naming the env var to set.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "yoagent"
version = "0.9.0"
version = "0.10.0"
edition = "2021"
description = "Simple, effective agent loop with tool execution and event streaming"
license = "MIT"
Expand Down
20 changes: 9 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ Or add to `Cargo.toml`:

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

Expand Down Expand Up @@ -179,19 +179,17 @@ Found 3 TODOs:
<summary>OpenAI-compatible provider example</summary>

```rust
use yoagent::provider::{ModelConfig, ProviderRegistry};
use yoagent::{Agent, provider::ModelConfig};

// Use a first-class OpenAI-compatible provider preset
let model = ModelConfig::groq("llama-3.3-70b-versatile", "Llama 3.3 70B");
// Pick a first-class preset — the provider is inferred from it, and the API
// key is read from that provider's conventional env var (GROQ_API_KEY here).
let mut agent = Agent::from_config(ModelConfig::groq("llama-3.3-70b-versatile", "Llama 3.3 70B"));

// Or Qwen / DashScope
let model = ModelConfig::qwen("qwen3.6-plus", "Qwen 3.6 Plus");
// Or Qwen / DashScope (DASHSCOPE_API_KEY):
let mut agent = Agent::from_config(ModelConfig::qwen("qwen3.6-plus", "Qwen 3.6 Plus"));

// Or Google Gemini
let model = ModelConfig::google("gemini-2.5-pro", "Gemini 2.5 Pro");

// Registry dispatches to the right provider
let registry = ProviderRegistry::default();
// Or Google Gemini (GEMINI_API_KEY):
let mut agent = Agent::from_config(ModelConfig::google("gemini-2.5-pro", "Gemini 2.5 Pro"));
```

</details>
Expand Down
2 changes: 1 addition & 1 deletion docs/concepts/agent-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ impl CompactionStrategy for MyCompaction {
}
}

let agent = Agent::new(provider)
let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5"))
.with_compaction_strategy(MyCompaction);
```

Expand Down
8 changes: 4 additions & 4 deletions docs/concepts/callbacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ yoagent provides three lifecycle callbacks that let you observe and control the
Called before each LLM call. Receives the current message history and the turn number (0-indexed). Return `false` to abort the loop.

```rust
let agent = Agent::new(provider)
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
Expand All @@ -26,7 +26,7 @@ use std::sync::{Arc, Mutex};
let total_cost = Arc::new(Mutex::new(0u64));
let cost_tracker = total_cost.clone();

let agent = Agent::new(provider)
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;
Expand All @@ -39,7 +39,7 @@ let agent = Agent::new(provider)
Called when the LLM returns a `StopReason::Error`. Receives the error message string.

```rust
let agent = Agent::new(provider)
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.
Expand All @@ -51,7 +51,7 @@ let agent = Agent::new(provider)
All callbacks are optional and independent:

```rust
let agent = Agent::new(provider)
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);
Expand Down
10 changes: 3 additions & 7 deletions docs/concepts/context-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,10 @@ When you set a `ModelConfig` but don't explicitly set a `ContextConfig`, the com

```rust
// MiniMax with 1M context → compacts at 800K (no manual config needed)
let agent = Agent::new(OpenAiCompatProvider)
.with_model_config(ModelConfig::minimax("MiniMax-Text-01", "MiniMax Text 01"))
.with_api_key(api_key);
let agent = Agent::from_config(ModelConfig::minimax("MiniMax-Text-01", "MiniMax Text 01"));

// Anthropic with 200K context → compacts at 160K
let agent = Agent::new(AnthropicProvider)
.with_model_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5"))
.with_api_key(api_key);
let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5"));
```

The priority chain:
Expand Down Expand Up @@ -159,7 +155,7 @@ When a limit is reached, the agent stops with a message like `"[Agent stopped: M
## Disabling Context Management

```rust
let agent = Agent::new(provider)
let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5"))
.without_context_management();
```

Expand Down
11 changes: 4 additions & 7 deletions docs/concepts/persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,8 @@ std::fs::write("conversation.json", &json)?;

// Later, in a new process:
let json = std::fs::read_to_string("conversation.json")?;
let mut agent = Agent::new(provider)
.with_system_prompt("You are helpful.")
.with_model("claude-sonnet-5")
.with_api_key(api_key);
let mut agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5"))
.with_system_prompt("You are helpful.");

agent.restore_messages(&json)?;

Expand All @@ -30,10 +28,9 @@ For constructing an agent with pre-existing history:

```rust
let saved: Vec<AgentMessage> = serde_json::from_str(&json)?;
let agent = Agent::new(provider)
let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5"))
.with_messages(saved)
.with_system_prompt("...")
.with_model("...");
.with_system_prompt("...");
```

## JSON Format
Expand Down
6 changes: 3 additions & 3 deletions docs/concepts/prompt-caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ In a multi-turn agent loop, each request sends the full context: system prompt +
| **DeepSeek** | Automatic prefix cache | Varies by model | None needed |
| **Google Gemini** | Implicit (automatic) | Varies | None needed |
| **Azure OpenAI** | Automatic (same as OpenAI) | 50% on hits | None needed |
| **Amazon Bedrock** | Automatic (where supported) | Varies | None needed |
| **Amazon Bedrock** | None (no automatic caching) | | Not supported |

### What Gets Cached (Anthropic)

Expand Down Expand Up @@ -44,7 +44,7 @@ Caching is **enabled by default** with automatic breakpoint placement. No config
```rust
use yoagent::{CacheConfig, CacheStrategy};

let agent = Agent::new(provider)
let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5"))
.with_cache_config(CacheConfig {
enabled: false,
..Default::default()
Expand All @@ -58,7 +58,7 @@ or OpenAI.
### Fine-Grained Control

```rust
let agent = Agent::new(provider)
let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5"))
.with_cache_config(CacheConfig {
enabled: true,
strategy: CacheStrategy::Manual {
Expand Down
6 changes: 3 additions & 3 deletions docs/concepts/retry.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ use yoagent::agent::Agent;
use yoagent::retry::RetryConfig;

// Default — 3 retries, exponential backoff (recommended)
let agent = Agent::new(provider);
let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5"));

// Custom — more retries, longer initial delay
let agent = Agent::new(provider)
let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5"))
.with_retry_config(RetryConfig {
max_retries: 5,
initial_delay_ms: 2000,
Expand All @@ -66,7 +66,7 @@ let agent = Agent::new(provider)
});

// Disable retries entirely
let agent = Agent::new(provider)
let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5"))
.with_retry_config(RetryConfig::none());
```

Expand Down
2 changes: 1 addition & 1 deletion docs/concepts/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ use yoagent::{Agent, SkillSet};

let skills = SkillSet::load(&["./skills"])?;

let agent = Agent::new(provider)
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);
Expand Down
47 changes: 27 additions & 20 deletions docs/concepts/sub-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,15 @@ Each sub-agent invocation starts a fresh conversation — no state leaks between
```rust
use std::sync::Arc;
use yoagent::sub_agent::SubAgentTool;
use yoagent::provider::AnthropicProvider;
use yoagent::provider::ModelConfig;
use yoagent::tools;

let researcher = SubAgentTool::new("researcher", Arc::new(AnthropicProvider))
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_model("claude-sonnet-5")
.with_api_key(&api_key)
.with_tools(vec![
Arc::new(tools::ReadFileTool::new()),
Arc::new(tools::SearchTool::new()),
Expand All @@ -41,10 +42,8 @@ let researcher = SubAgentTool::new("researcher", Arc::new(AnthropicProvider))
```rust
use yoagent::agent::Agent;

let mut agent = Agent::new(AnthropicProvider)
let mut agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5"))
.with_system_prompt("You coordinate between sub-agents.")
.with_model("claude-sonnet-5")
.with_api_key(api_key)
.with_sub_agent(researcher)
.with_sub_agent(coder);
```
Expand All @@ -62,8 +61,8 @@ When the parent LLM calls multiple sub-agents in a single response, they run con
| `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`) |
| `with_model()` / `with_api_key()` | Can use a different model than the parent |
| `with_model_config()` | Set `ModelConfig` for non-Anthropic providers (base URL, compat flags, etc.) |
| `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 |
Expand Down Expand Up @@ -91,10 +90,12 @@ use yoagent::shared_state::SharedState;
let state = SharedState::new();
state.set("ci_log", large_log_text).await.unwrap();

let analyzer = SubAgentTool::new("analyzer", provider.clone())
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_model("claude-sonnet-5")
.with_api_key(&api_key)
.with_shared_state(state.clone()); // opt-in
```

Expand All @@ -121,9 +122,17 @@ let summary = state.get("summary").await.expect("sub-agent wrote this");
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`.

```rust
let error_analyst = SubAgentTool::new("error_analyst", provider.clone())
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::new("perf_analyst", provider.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
Expand Down Expand Up @@ -176,15 +185,13 @@ See [`examples/shared_state.rs`](../../examples/shared_state.rs) for a complete
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:

```rust
use yoagent::provider::{OpenAiCompatProvider, model::ModelConfig};
use yoagent::provider::ModelConfig;

let provider = Arc::new(OpenAiCompatProvider);
let model_config = ModelConfig::xai("grok-4-1-fast-reasoning", "Grok 3 Mini Fast");

let analyst = SubAgentTool::new("analyst", provider)
.with_model(&model_config.id)
.with_api_key(&xai_api_key)
.with_model_config(model_config)
// `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![...]);
```

Expand Down
Loading
Loading