diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2306c36 --- /dev/null +++ b/CHANGELOG.md @@ -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(®istry, ModelConfig) -> Result` + — 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>` + and `StreamProvider::protocol() -> Option`. +- 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. diff --git a/Cargo.toml b/Cargo.toml index cee4303..a24bdda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 057c072..7bc09ac 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ Or add to `Cargo.toml`: ```toml [dependencies] -yoagent = "0.9" +yoagent = "0.10" tokio = { version = "1", features = ["full"] } ``` @@ -179,19 +179,17 @@ Found 3 TODOs: OpenAI-compatible provider example ```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")); ``` diff --git a/docs/concepts/agent-loop.md b/docs/concepts/agent-loop.md index b2cce4d..b0e6081 100644 --- a/docs/concepts/agent-loop.md +++ b/docs/concepts/agent-loop.md @@ -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); ``` diff --git a/docs/concepts/callbacks.md b/docs/concepts/callbacks.md index f06fb58..d4c5202 100644 --- a/docs/concepts/callbacks.md +++ b/docs/concepts/callbacks.md @@ -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 @@ -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; @@ -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. @@ -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); diff --git a/docs/concepts/context-management.md b/docs/concepts/context-management.md index d1975fa..03987c6 100644 --- a/docs/concepts/context-management.md +++ b/docs/concepts/context-management.md @@ -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: @@ -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(); ``` diff --git a/docs/concepts/persistence.md b/docs/concepts/persistence.md index 75e4768..04def37 100644 --- a/docs/concepts/persistence.md +++ b/docs/concepts/persistence.md @@ -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)?; @@ -30,10 +28,9 @@ For constructing an agent with pre-existing history: ```rust let saved: Vec = 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 diff --git a/docs/concepts/prompt-caching.md b/docs/concepts/prompt-caching.md index 0849ac9..fd63884 100644 --- a/docs/concepts/prompt-caching.md +++ b/docs/concepts/prompt-caching.md @@ -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) @@ -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() @@ -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 { diff --git a/docs/concepts/retry.md b/docs/concepts/retry.md index 65efb0b..2af531c 100644 --- a/docs/concepts/retry.md +++ b/docs/concepts/retry.md @@ -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, @@ -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()); ``` diff --git a/docs/concepts/skills.md b/docs/concepts/skills.md index 4e20497..315f88e 100644 --- a/docs/concepts/skills.md +++ b/docs/concepts/skills.md @@ -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); diff --git a/docs/concepts/sub-agents.md b/docs/concepts/sub-agents.md index e8483b7..a59e176 100644 --- a/docs/concepts/sub-agents.md +++ b/docs/concepts/sub-agents.md @@ -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()), @@ -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); ``` @@ -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>`) | | `with_max_turns(N)` | Turn limit (default: 10). Primary guard against runaway execution. | | `with_thinking()` | Enable extended thinking for the sub-agent | @@ -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 ``` @@ -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 @@ -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![...]); ``` diff --git a/docs/concepts/tools.md b/docs/concepts/tools.md index 47c9f99..3e87e64 100644 --- a/docs/concepts/tools.md +++ b/docs/concepts/tools.md @@ -130,7 +130,7 @@ use yoagent::tools::default_tools; let mut tools = default_tools(); tools.push(Box::new(WeatherTool)); -let agent = Agent::new(provider).with_tools(tools); +let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")).with_tools(tools); ``` ## Error Handling @@ -289,7 +289,7 @@ Here's a complete example: a CLI agent with a deploy tool that streams progress. ```rust use yoagent::agent::Agent; -use yoagent::provider::AnthropicProvider; +use yoagent::provider::ModelConfig; use yoagent::types::*; /// A tool that deploys an app and streams each step. @@ -353,10 +353,8 @@ impl AgentTool for DeployTool { #[tokio::main] async fn main() { - let mut agent = Agent::new(AnthropicProvider) + let mut agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_system_prompt("You are a deployment assistant.") - .with_model("claude-sonnet-5") - .with_api_key(std::env::var("ANTHROPIC_API_KEY").unwrap()) .with_tools(vec![Box::new(DeployTool)]); let mut rx = agent.prompt("Deploy to production").await; @@ -436,14 +434,14 @@ use yoagent::agent::Agent; use yoagent::types::ToolExecutionStrategy; // Default — parallel (fastest) -let agent = Agent::new(provider); +let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")); // Sequential (debug / shared state) -let agent = Agent::new(provider) +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::new(provider) +let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_tool_execution(ToolExecutionStrategy::Batched { size: 3 }); ``` diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 89b7582..6a94770 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -2,14 +2,15 @@ ## Requirements -- Rust 2021 edition (1.56+, recommended 1.75+) +- Rust 1.86+ (2021 edition) - Tokio async runtime ## Add to Cargo.toml ```toml [dependencies] -yoagent = "0.5" +yoagent = "0.10" +tokio = { version = "1", features = ["full"] } ``` ## Dependencies @@ -33,11 +34,11 @@ All providers and built-in tools are included by default. Optional features: | Feature | Dependencies | Description | |---------|-------------|-------------| -| `openapi` | `openapiv3`, `serde_yaml` | Auto-generate tools from OpenAPI 3.0 specs | +| `openapi` | `openapiv3`, `serde_yaml_ng` | Auto-generate tools from OpenAPI 3.0 specs | Enable in `Cargo.toml`: ```toml [dependencies] -yoagent = { version = "0.5", features = ["openapi"] } +yoagent = { version = "0.10", features = ["openapi"] } ``` diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index 4e47ad3..2aeb716 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -15,14 +15,12 @@ Use `with_mcp_server_stdio()` to spawn an MCP server process and register its to ```rust use yoagent::Agent; -use yoagent::provider::AnthropicProvider; +use yoagent::provider::ModelConfig; #[tokio::main] async fn main() -> Result<(), Box> { - let mut agent = Agent::new(AnthropicProvider) + 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_model("claude-sonnet-5") - .with_api_key(std::env::var("ANTHROPIC_API_KEY")?) .with_mcp_server_stdio( "npx", &["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], @@ -44,7 +42,7 @@ use std::collections::HashMap; let mut env = HashMap::new(); env.insert("API_TOKEN".into(), "secret".into()); -let agent = Agent::new(provider) +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?; ``` @@ -54,7 +52,7 @@ let agent = Agent::new(provider) For remote MCP servers exposed over HTTP: ```rust -let agent = Agent::new(provider) +let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_mcp_server_http("http://localhost:8080/mcp") .await?; ``` @@ -75,7 +73,7 @@ MCP tools appear alongside built-in tools. The LLM sees them with their original ```rust use yoagent::tools::default_tools; -let agent = Agent::new(provider) +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?; diff --git a/docs/guides/openapi.md b/docs/guides/openapi.md index 4889f48..3651e4c 100644 --- a/docs/guides/openapi.md +++ b/docs/guides/openapi.md @@ -9,17 +9,15 @@ Auto-generate `AgentTool` implementations from OpenAPI 3.0 specs. Point an agent ```rust use yoagent::Agent; use yoagent::openapi::{OpenApiToolAdapter, OpenApiConfig, OperationFilter}; -use yoagent::provider::AnthropicProvider; +use yoagent::provider::ModelConfig; #[tokio::main] async fn main() -> Result<(), Box> { let config = OpenApiConfig::new() .with_bearer_token("sk-..."); - let agent = Agent::new(AnthropicProvider) + let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")) .with_system_prompt("You are an API assistant.") - .with_model("claude-sonnet-5") - .with_api_key(std::env::var("ANTHROPIC_API_KEY")?) .with_openapi_file("petstore.yaml", config, &OperationFilter::All) .await?; @@ -127,7 +125,7 @@ OpenAPI tools work alongside built-in tools and MCP tools: ```rust use yoagent::tools::default_tools; -let agent = Agent::new(provider) +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?; diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index 5972044..ecb425b 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -5,11 +5,9 @@ ## Usage ```rust -use yoagent::provider::AnthropicProvider; +use yoagent::provider::ModelConfig; -let agent = Agent::new(AnthropicProvider) - .with_model("claude-sonnet-5") - .with_api_key(std::env::var("ANTHROPIC_API_KEY").unwrap()); +let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")); ``` ## Features diff --git a/docs/providers/azure-openai.md b/docs/providers/azure-openai.md index f70e923..0c9d983 100644 --- a/docs/providers/azure-openai.md +++ b/docs/providers/azure-openai.md @@ -5,11 +5,17 @@ ## Usage ```rust -use yoagent::provider::AzureOpenAiProvider; - -let agent = Agent::new(AzureOpenAiProvider) - .with_model("gpt-5.5") - .with_api_key(std::env::var("AZURE_OPENAI_API_KEY").unwrap()); +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 diff --git a/docs/providers/bedrock.md b/docs/providers/bedrock.md index 52c795a..1bcfec9 100644 --- a/docs/providers/bedrock.md +++ b/docs/providers/bedrock.md @@ -5,10 +5,16 @@ ## Usage ```rust -use yoagent::provider::BedrockProvider; - -let agent = Agent::new(BedrockProvider) - .with_model("anthropic.claude-opus-4-8") +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 ``` diff --git a/docs/providers/google.md b/docs/providers/google.md index a40f696..45c296e 100644 --- a/docs/providers/google.md +++ b/docs/providers/google.md @@ -8,11 +8,9 @@ Two providers for Google's Gemini models: ## Google AI Studio ```rust -use yoagent::provider::GoogleProvider; +use yoagent::provider::ModelConfig; -let agent = Agent::new(GoogleProvider) - .with_model("gemini-2.5-flash") - .with_api_key(std::env::var("GOOGLE_API_KEY").unwrap()); +let agent = Agent::from_config(ModelConfig::google("gemini-2.5-flash", "Gemini 2.5 Flash")); ``` ### API Details diff --git a/docs/providers/model-presets.md b/docs/providers/model-presets.md index fe9200f..526a2d6 100644 --- a/docs/providers/model-presets.md +++ b/docs/providers/model-presets.md @@ -38,15 +38,12 @@ The named presets (`claude_fable_5`, `claude_opus_4_8`, `claude_sonnet_5`, `clau These constructors all use `OpenAiCompatProvider`: ```rust -use yoagent::provider::{ModelConfig, OpenAiCompatProvider}; - -let agent = Agent::new(OpenAiCompatProvider) - .with_model_config(ModelConfig::deepseek( - "deepseek-v4-flash", - "DeepSeek V4 Flash", - )) - .with_model("deepseek-v4-flash") - .with_api_key(std::env::var("DEEPSEEK_API_KEY").unwrap()); +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](openai-compat.md) for the full quirk-flag list. @@ -135,15 +132,11 @@ yoagent also sends DeepSeek's current request shape: For legacy aliases, set `ThinkingLevel` to match the alias behavior: ```rust -let chat_agent = Agent::new(OpenAiCompatProvider) - .with_model_config(ModelConfig::deepseek("deepseek-chat", "DeepSeek Chat")) - .with_model("deepseek-chat") - .with_thinking_level(ThinkingLevel::Off); - -let reasoner_agent = Agent::new(OpenAiCompatProvider) - .with_model_config(ModelConfig::deepseek("deepseek-reasoner", "DeepSeek Reasoner")) - .with_model("deepseek-reasoner") - .with_thinking_level(ThinkingLevel::High); +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. diff --git a/docs/providers/openai-compat.md b/docs/providers/openai-compat.md index 6010be5..ead0b5c 100644 --- a/docs/providers/openai-compat.md +++ b/docs/providers/openai-compat.md @@ -9,11 +9,9 @@ For the first-class `ModelConfig::*` constructors and default model metadata, se Requires a `ModelConfig` with `compat` flags set in `StreamConfig.model_config`: ```rust -use yoagent::provider::{OpenAiCompatProvider, ModelConfig}; +use yoagent::provider::ModelConfig; -let agent = Agent::new(OpenAiCompatProvider) - .with_model("gpt-5.5") - .with_api_key(std::env::var("OPENAI_API_KEY").unwrap()); +let agent = Agent::from_config(ModelConfig::openai("gpt-5.5", "GPT-5.5")); ``` ## OpenAiCompat Quirk Flags @@ -98,21 +96,16 @@ Use `ModelConfig::ollama()` for Ollama, or `ModelConfig::local()` for any other ```rust use yoagent::agent::Agent; -use yoagent::provider::{OpenAiCompatProvider, ModelConfig}; +use yoagent::provider::ModelConfig; -let agent = Agent::new(OpenAiCompatProvider) - .with_model_config(ModelConfig::local("http://localhost:1234/v1", "my-model")) - .with_model("my-model") - .with_api_key(""); // empty string OK for local +// 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: ```rust -let agent = Agent::new(OpenAiCompatProvider) - .with_model_config(ModelConfig::ollama("http://localhost:11434/v1", "llama3.1:8b")) - .with_model("llama3.1:8b") - .with_api_key(""); +let agent = Agent::from_config(ModelConfig::ollama("http://localhost:11434/v1", "llama3.1:8b")); ``` Or via the CLI example: @@ -161,7 +154,7 @@ Copilot token as the API key: ```rust use yoagent::agent::Agent; -use yoagent::provider::{OpenAiCompatProvider, ModelConfig, OpenAiCompat}; +use yoagent::provider::{ModelConfig, OpenAiCompat}; let mut config = ModelConfig::openai_compat( "https://api.githubcopilot.com", @@ -173,9 +166,7 @@ let mut config = ModelConfig::openai_compat( config.headers.insert("Copilot-Integration-Id".into(), "vscode-chat".into()); config.headers.insert("Editor-Version".into(), "Neovim/0.10.0".into()); -let agent = Agent::new(OpenAiCompatProvider) - .with_model_config(config) - .with_model("gpt-5.5") +let agent = Agent::from_config(config) .with_api_key(copilot_token); // see below ``` diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md index 1391caa..e169b9c 100644 --- a/docs/providers/opencode.md +++ b/docs/providers/opencode.md @@ -18,27 +18,19 @@ The routing table mirrors the Zen/Go endpoint docs as of mid-2026. OpenCode can ## Usage -Because `Agent::new()` takes a concrete provider, pair the preset with the provider matching its protocol (check `config.api`): +`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: ```rust -use yoagent::provider::{AnthropicProvider, ModelConfig, OpenAiCompatProvider}; +use yoagent::provider::ModelConfig; use yoagent::Agent; -let key = std::env::var("OPENCODE_API_KEY").unwrap(); - // Chat-completions model (GLM, Kimi, DeepSeek, ...) -let config = ModelConfig::opencode_zen("glm-5.2"); -let agent = Agent::new(OpenAiCompatProvider) - .with_model(&config.id) - .with_api_key(&key) - .with_model_config(config); +let agent = Agent::from_config(ModelConfig::opencode_zen("glm-5.2")); // Claude/Qwen model — Anthropic Messages protocol -let config = ModelConfig::opencode_zen("claude-sonnet-5"); -let agent = Agent::new(AnthropicProvider) - .with_model(&config.id) - .with_api_key(&key) - .with_model_config(config); +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. diff --git a/docs/reference/api.md b/docs/reference/api.md index 240ee53..63d6303 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -44,12 +44,14 @@ High-level stateful wrapper around the agent loop. ### Construction ```rust -let agent = Agent::new(provider); +let agent = Agent::from_config(ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5")); ``` | Signature | Description | |-----------|-------------| -| `Agent::new(provider: impl StreamProvider + 'static) -> Self` | Create a new agent with the given provider | +| `Agent::from_config(config: ModelConfig) -> Self` | Build from a `ModelConfig` — auto-selects the built-in provider for the config's protocol and resolves the API key from the provider's conventional env var (primary constructor) | +| `Agent::from_provider(provider: impl StreamProvider + 'static, config: ModelConfig) -> Self` | Build from an explicit provider plus its `ModelConfig` (custom providers and test doubles — pair with `ModelConfig::mock()`) | +| `Agent::from_config_with(registry: &ProviderRegistry, config: ModelConfig) -> Result` | Like `from_config`, but resolves the provider from a caller-supplied registry | ### Builder Methods @@ -60,11 +62,9 @@ All return `Self` for chaining (unless noted as `Result`). | Method | Description | |--------|-------------| | `with_system_prompt(prompt) -> Self` | Set the system prompt | -| `with_model(model) -> Self` | Set the model identifier | -| `with_api_key(key) -> Self` | Set the API key | +| `with_api_key(key) -> Self` | Override the env-resolved API key | | `with_thinking(level: ThinkingLevel) -> Self` | Set thinking level (`Off`, `Minimal`, `Low`, `Medium`, `High`) | | `with_max_tokens(max: u32) -> Self` | Set max output tokens | -| `with_model_config(config: ModelConfig) -> Self` | Set model config (base URL, headers, compat flags) for multi-provider support | **Tools & Integrations** @@ -170,7 +170,11 @@ Delegates tasks to a child agent loop. ### Construction ```rust -let sub = SubAgentTool::new("name", Arc::new(provider)); +// 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 @@ -181,9 +185,7 @@ All return `Self` for chaining. |--------|-------------| | `with_description(desc) -> Self` | What the parent LLM sees (helps it decide when to delegate) | | `with_system_prompt(prompt) -> Self` | The sub-agent's own instructions | -| `with_model(model) -> Self` | Set the model identifier | -| `with_api_key(key) -> Self` | Set the API key | -| `with_model_config(config: ModelConfig) -> Self` | Set model config for non-Anthropic providers (base URL, compat flags, etc.) | +| `with_api_key(key) -> Self` | Override the env-resolved API key | | `with_tools(tools: Vec>) -> Self` | Tools available to the sub-agent | | `with_shared_state(state: SharedState) -> Self` | Attach a shared key-value store (injects `shared_state` tool automatically) | | `with_max_turns(N) -> Self` | Turn limit (default: 10) | diff --git a/examples/code_review.rs b/examples/code_review.rs index 6faa94e..df6790f 100644 --- a/examples/code_review.rs +++ b/examples/code_review.rs @@ -13,7 +13,7 @@ //! ANTHROPIC_API_KEY=sk-... cargo run --example code_review -- src/shared_state.rs use std::sync::{Arc, Mutex}; -use yoagent::provider::{AnthropicProvider, StreamProvider}; +use yoagent::provider::{AnthropicProvider, ModelConfig, StreamProvider}; use yoagent::shared_state::SharedState; use yoagent::sub_agent::SubAgentTool; use yoagent::*; @@ -21,7 +21,7 @@ use yoagent::*; #[tokio::main] async fn main() { let api_key = std::env::var("ANTHROPIC_API_KEY").expect("Set ANTHROPIC_API_KEY"); - let model = "claude-sonnet-5"; + let config = ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5"); let provider: Arc = Arc::new(AnthropicProvider); // --- Read the target file from CLI args --- @@ -50,47 +50,47 @@ async fn main() { // --- Three reviewers, each focused on a different aspect --- - let bug_reviewer = SubAgentTool::new("bug_reviewer", Arc::clone(&provider)) - .with_description("Reviews code for bugs and logic errors") - .with_system_prompt( - "You are a bug-finding specialist. Read the source code from shared state \ + let bug_reviewer = + SubAgentTool::from_provider("bug_reviewer", Arc::clone(&provider), config.clone()) + .with_description("Reviews code for bugs and logic errors") + .with_system_prompt( + "You are a bug-finding specialist. Read the source code from shared state \ (key: 'source_code'), look for bugs, logic errors, off-by-one errors, \ race conditions, and potential panics. Write your findings to shared state \ under key 'bugs_review'. Format: bullet points, each with line reference \ and severity (critical/warning/info). If no bugs found, say so.", - ) - .with_model(model) - .with_api_key(&api_key) - .with_shared_state(state.clone()) - .with_max_turns(5); - - let quality_reviewer = SubAgentTool::new("quality_reviewer", Arc::clone(&provider)) - .with_description("Reviews code quality and style") - .with_system_prompt( - "You are a code quality reviewer. Read the source code from shared state \ + ) + .with_api_key(&api_key) + .with_shared_state(state.clone()) + .with_max_turns(5); + + let quality_reviewer = + SubAgentTool::from_provider("quality_reviewer", Arc::clone(&provider), config.clone()) + .with_description("Reviews code quality and style") + .with_system_prompt( + "You are a code quality reviewer. Read the source code from shared state \ (key: 'source_code'), evaluate naming, structure, idiomatic usage, \ error handling patterns, and API design. Write your findings to shared state \ under key 'quality_review'. Format: bullet points with specific suggestions. \ Mention what's done well too.", - ) - .with_model(model) - .with_api_key(&api_key) - .with_shared_state(state.clone()) - .with_max_turns(5); - - let docs_reviewer = SubAgentTool::new("docs_reviewer", Arc::clone(&provider)) - .with_description("Reviews documentation completeness") - .with_system_prompt( - "You are a documentation reviewer. Read the source code from shared state \ + ) + .with_api_key(&api_key) + .with_shared_state(state.clone()) + .with_max_turns(5); + + let docs_reviewer = + SubAgentTool::from_provider("docs_reviewer", Arc::clone(&provider), config.clone()) + .with_description("Reviews documentation completeness") + .with_system_prompt( + "You are a documentation reviewer. Read the source code from shared state \ (key: 'source_code') and the file path (key: 'file_path'). Evaluate: \ are public items documented? Are doc comments accurate? Are edge cases \ explained? Are examples provided where helpful? Write findings to shared \ state under key 'docs_review'. Format: bullet points.", - ) - .with_model(model) - .with_api_key(&api_key) - .with_shared_state(state.clone()) - .with_max_turns(5); + ) + .with_api_key(&api_key) + .with_shared_state(state.clone()) + .with_max_turns(5); // --- Run all three in parallel with streaming --- println!("Dispatching 3 reviewers in parallel...\n"); diff --git a/examples/rlm.rs b/examples/rlm.rs index 326ebe0..67cc4d5 100644 --- a/examples/rlm.rs +++ b/examples/rlm.rs @@ -54,32 +54,32 @@ async fn main() { // --- Level 2: file_analyst (leaf agent) --- // Has read_file + shared_state. Reads a file, writes summary to shared state. - let file_analyst = SubAgentTool::new("file_analyst", Arc::clone(&provider)) - .with_description( - "Analyzes a single source file in depth. \ + let file_analyst = + SubAgentTool::from_provider("file_analyst", Arc::clone(&provider), model_config.clone()) + .with_description( + "Analyzes a single source file in depth. \ Call with a task specifying the file path to analyze.", - ) - .with_system_prompt( - "You are a file-level code analyst. When given a file to analyze:\n\ + ) + .with_system_prompt( + "You are a file-level code analyst. When given a file to analyze:\n\ 1. Use read_file to read the file content\n\ 2. Analyze it: purpose, key types/functions, design patterns, quality\n\ 3. Write a concise summary (under 200 words) to shared state with \ key 'summary:'\n\n\ Be specific and technical. Focus on what makes this code interesting.", - ) - .with_model(&model_config.id) - .with_api_key(&api_key) - .with_model_config(model_config.clone()) - .with_shared_state(state.clone()) - .with_tools(vec![Arc::new(tools::ReadFileTool::new())]) - .with_max_turns(5); + ) + .with_api_key(&api_key) + .with_shared_state(state.clone()) + .with_tools(vec![Arc::new(tools::ReadFileTool::new())]) + .with_max_turns(5); // --- Level 1: lead_analyst (orchestrator agent) --- // Has list_files + read_file to explore, file_analyst to delegate, shared_state for results. - let lead_analyst = SubAgentTool::new("lead_analyst", Arc::clone(&provider)) - .with_description("Orchestrates codebase analysis") - .with_system_prompt( - "You are a lead code analyst orchestrating a codebase review.\n\n\ + let lead_analyst = + SubAgentTool::from_provider("lead_analyst", Arc::clone(&provider), model_config) + .with_description("Orchestrates codebase analysis") + .with_system_prompt( + "You are a lead code analyst orchestrating a codebase review.\n\n\ IMPORTANT: Only analyze files within the target directory. Do NOT explore \ parent directories or other parts of the project.\n\n\ Steps:\n\ @@ -93,17 +93,15 @@ async fn main() { 6. Write a final synthesis report to shared state under key 'final_report'\n\n\ The final report should identify cross-cutting themes, architectural patterns, \ and how the files relate to each other. Keep it under 300 words.", - ) - .with_model(&model_config.id) - .with_api_key(&api_key) - .with_model_config(model_config) - .with_shared_state(state.clone()) - .with_tools(vec![ - Arc::new(tools::ListFilesTool::new()), - Arc::new(tools::ReadFileTool::new()), - Arc::new(file_analyst), - ]) - .with_max_turns(25); + ) + .with_api_key(&api_key) + .with_shared_state(state.clone()) + .with_tools(vec![ + Arc::new(tools::ListFilesTool::new()), + Arc::new(tools::ReadFileTool::new()), + Arc::new(file_analyst), + ]) + .with_max_turns(25); // --- Parent: single call triggers the full recursive chain --- let buf: Arc> = Arc::new(Mutex::new(String::new())); diff --git a/examples/shared_state.rs b/examples/shared_state.rs index 81b6e46..8d85e4a 100644 --- a/examples/shared_state.rs +++ b/examples/shared_state.rs @@ -14,7 +14,7 @@ //! ANTHROPIC_API_KEY=sk-... cargo run --example shared_state use std::sync::Arc; -use yoagent::provider::{AnthropicProvider, StreamProvider}; +use yoagent::provider::{AnthropicProvider, ModelConfig, StreamProvider}; use yoagent::shared_state::SharedState; use yoagent::sub_agent::SubAgentTool; use yoagent::*; @@ -22,7 +22,7 @@ use yoagent::*; #[tokio::main] async fn main() { let api_key = std::env::var("ANTHROPIC_API_KEY").expect("Set ANTHROPIC_API_KEY"); - let model = "claude-sonnet-5"; + let config = ModelConfig::anthropic("claude-sonnet-5", "Claude Sonnet 5"); let provider: Arc = Arc::new(AnthropicProvider); // --- The artifact: a large CI log (simulated) --- @@ -67,41 +67,41 @@ async fn main() { // --- Three sub-agents, each analyzing a different aspect --- - let error_analyst = SubAgentTool::new("error_analyst", Arc::clone(&provider)) - .with_description("Analyzes test failures in CI logs") - .with_system_prompt( - "You analyze CI logs for test failures. Read the log from shared state, \ + let error_analyst = + SubAgentTool::from_provider("error_analyst", Arc::clone(&provider), config.clone()) + .with_description("Analyzes test failures in CI logs") + .with_system_prompt( + "You analyze CI logs for test failures. Read the log from shared state, \ identify each failure, its root cause, and write a concise summary back \ to shared state under 'errors_summary'. Be brief — bullet points only.", - ) - .with_model(model) - .with_api_key(&api_key) - .with_shared_state(state.clone()) - .with_max_turns(5); - - let perf_analyst = SubAgentTool::new("perf_analyst", Arc::clone(&provider)) - .with_description("Analyzes performance issues in CI logs") - .with_system_prompt( - "You analyze CI logs for performance issues. Read the log from shared state, \ + ) + .with_api_key(&api_key) + .with_shared_state(state.clone()) + .with_max_turns(5); + + let perf_analyst = + SubAgentTool::from_provider("perf_analyst", Arc::clone(&provider), config.clone()) + .with_description("Analyzes performance issues in CI logs") + .with_system_prompt( + "You analyze CI logs for performance issues. Read the log from shared state, \ identify slow tests and bottlenecks, and write a concise summary back \ to shared state under 'perf_summary'. Be brief — bullet points only.", - ) - .with_model(model) - .with_api_key(&api_key) - .with_shared_state(state.clone()) - .with_max_turns(5); - - let flaky_analyst = SubAgentTool::new("flaky_analyst", Arc::clone(&provider)) - .with_description("Identifies flaky tests in CI logs") - .with_system_prompt( - "You analyze CI logs for flaky/unreliable tests. Read the log from shared state, \ + ) + .with_api_key(&api_key) + .with_shared_state(state.clone()) + .with_max_turns(5); + + let flaky_analyst = + SubAgentTool::from_provider("flaky_analyst", Arc::clone(&provider), config.clone()) + .with_description("Identifies flaky tests in CI logs") + .with_system_prompt( + "You analyze CI logs for flaky/unreliable tests. Read the log from shared state, \ identify tests that are flaky or infrastructure-dependent, and write a concise \ summary back to shared state under 'flaky_summary'. Be brief — bullet points only.", - ) - .with_model(model) - .with_api_key(&api_key) - .with_shared_state(state.clone()) - .with_max_turns(5); + ) + .with_api_key(&api_key) + .with_shared_state(state.clone()) + .with_max_turns(5); // --- Run all three in parallel --- println!("Dispatching 3 sub-agents in parallel...\n"); diff --git a/src/agent.rs b/src/agent.rs index d184f88..ec584a0 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -98,7 +98,13 @@ impl Agent { /// Prefer [`from_config`](Self::from_config) (provider + env key resolved /// from one `ModelConfig`) or [`from_provider`](Self::from_provider) for a /// custom provider; both avoid the provider↔config mismatch this - /// constructor allows. Kept for backward compatibility. + /// constructor allows. + #[deprecated( + since = "0.10.0", + note = "use Agent::from_config(config) — provider + env key resolved \ + automatically — or Agent::from_provider(provider, config) for a \ + custom provider; will be removed in 1.0" + )] pub fn new(provider: impl StreamProvider + 'static) -> Self { Self::with_provider_arc(Arc::new(provider)) } @@ -248,6 +254,11 @@ impl Agent { self } + #[deprecated( + since = "0.10.0", + note = "the model id now comes from the ModelConfig passed to \ + Agent::from_config / from_provider; will be removed in 1.0" + )] pub fn with_model(mut self, model: impl Into) -> Self { self.model = model.into(); self @@ -268,6 +279,11 @@ impl Agent { self } + #[deprecated( + since = "0.10.0", + note = "pass the ModelConfig to Agent::from_config(config) or \ + from_provider(provider, config) instead; will be removed in 1.0" + )] pub fn with_model_config(mut self, config: ModelConfig) -> Self { self.model_config = Some(config); self @@ -690,10 +706,9 @@ impl Agent { /// /// ```rust,no_run /// # use yoagent::Agent; - /// # use yoagent::provider::MockProvider; + /// # use yoagent::provider::{MockProvider, ModelConfig}; /// # async fn example() { - /// let mut agent = Agent::new(MockProvider::text("hi")) - /// .with_model("mock").with_api_key("test"); + /// let mut agent = Agent::from_provider(MockProvider::text("hi"), ModelConfig::mock()); /// let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); /// tokio::spawn(async move { /// while let Some(event) = rx.recv().await { /* real-time */ } diff --git a/src/sub_agent.rs b/src/sub_agent.rs index 4ac62dc..ef61b6c 100644 --- a/src/sub_agent.rs +++ b/src/sub_agent.rs @@ -15,14 +15,15 @@ //! //! ```rust,no_run //! use yoagent::sub_agent::SubAgentTool; -//! use yoagent::provider::AnthropicProvider; -//! use std::sync::Arc; +//! use yoagent::provider::ModelConfig; //! -//! let researcher = SubAgentTool::new("researcher", Arc::new(AnthropicProvider)) -//! .with_description("Searches codebases and documents") -//! .with_system_prompt("You are a research assistant.") -//! .with_model("claude-sonnet-4-20250514") -//! .with_api_key("sk-..."); +//! // Provider selected from the config's protocol; key from ANTHROPIC_API_KEY. +//! let researcher = SubAgentTool::from_config( +//! "researcher", +//! ModelConfig::anthropic("claude-sonnet-5", "Sonnet 5"), +//! ) +//! .with_description("Searches codebases and documents") +//! .with_system_prompt("You are a research assistant."); //! ``` use crate::agent_loop::{agent_loop, AgentLoopConfig}; @@ -66,7 +67,19 @@ pub struct SubAgentTool { impl SubAgentTool { /// Create a new sub-agent tool with a name and provider. + #[deprecated( + since = "0.10.0", + note = "use SubAgentTool::from_config(name, config) — provider + env key \ + resolved automatically — or SubAgentTool::from_provider(name, provider, config) \ + for a custom provider; will be removed in 1.0" + )] pub fn new(name: impl Into, provider: Arc) -> Self { + Self::build(name, provider) + } + + /// Internal constructor shared by `new` and the `from_*` builders (not + /// deprecated, so the builders don't trip the deprecation lint). + fn build(name: impl Into, provider: Arc) -> Self { let name = name.into(); Self { tool_description: format!("Delegate a task to the '{}' sub-agent", name), @@ -122,7 +135,7 @@ impl SubAgentTool { let provider = registry .resolve(&config.api) .ok_or(crate::AgentBuildError::NoProviderForProtocol(config.api))?; - Ok(Self::new(name, provider).configured_for(config)) + Ok(Self::build(name, provider).configured_for(config)) } /// Create a sub-agent from a name, explicit provider, and [`ModelConfig`]. @@ -135,7 +148,7 @@ impl SubAgentTool { provider: Arc, config: ModelConfig, ) -> Self { - Self::new(name, provider).configured_for(config) + Self::build(name, provider).configured_for(config) } /// Set the model id and stash the config on a freshly-constructed @@ -168,6 +181,11 @@ impl SubAgentTool { self } + #[deprecated( + since = "0.10.0", + note = "the model id now comes from the ModelConfig passed to \ + SubAgentTool::from_config / from_provider; will be removed in 1.0" + )] pub fn with_model(mut self, model: impl Into) -> Self { self.model = model.into(); self @@ -240,6 +258,11 @@ impl SubAgentTool { /// Set the model configuration for multi-provider support. /// Required for non-Anthropic providers (OpenAI-compat, Google, etc.) /// to specify base URL, compat flags, and other provider-specific settings. + #[deprecated( + since = "0.10.0", + note = "pass the ModelConfig to SubAgentTool::from_config(name, config) or \ + from_provider(name, provider, config) instead; will be removed in 1.0" + )] pub fn with_model_config(mut self, config: ModelConfig) -> Self { self.model_config = Some(config); self diff --git a/tests/agent_test.rs b/tests/agent_test.rs index 4dcad7f..c4f83d5 100644 --- a/tests/agent_test.rs +++ b/tests/agent_test.rs @@ -6,15 +6,14 @@ use tokio::sync::mpsc; use yoagent::agent::Agent; use yoagent::provider::mock::*; use yoagent::provider::MockProvider; +use yoagent::provider::ModelConfig; use yoagent::*; #[tokio::test] async fn test_agent_simple_prompt() { let provider = MockProvider::text("Hello!"); - let mut agent = Agent::new(provider) - .with_system_prompt("You are helpful.") - .with_model("mock") - .with_api_key("test"); + let mut agent = + Agent::from_provider(provider, ModelConfig::mock()).with_system_prompt("You are helpful."); let mut rx = agent.prompt("Hi there").await; @@ -32,10 +31,7 @@ async fn test_agent_simple_prompt() { #[tokio::test] async fn test_agent_reset() { let provider = MockProvider::text("Hello!"); - let mut agent = Agent::new(provider) - .with_system_prompt("test") - .with_model("mock") - .with_api_key("test"); + let mut agent = Agent::from_provider(provider, ModelConfig::mock()).with_system_prompt("test"); let mut rx = agent.prompt("Hi").await; while rx.recv().await.is_some() {} @@ -87,10 +83,8 @@ async fn test_agent_with_tools() { MockResponse::Text("Echoed: hello".into()), ]); - let mut agent = Agent::new(provider) + let mut agent = Agent::from_provider(provider, ModelConfig::mock()) .with_system_prompt("test") - .with_model("mock") - .with_api_key("test") .with_tools(vec![Box::new(EchoTool)]); let mut rx = agent.prompt("Echo hello").await; @@ -101,7 +95,10 @@ async fn test_agent_with_tools() { assert_eq!(agent.messages().len(), 4); } +// Deliberately exercises the deprecated builder chain (`new` + `with_model` + +// `with_api_key`) to keep coverage of that still-present API until 1.0. #[tokio::test] +#[allow(deprecated)] async fn test_agent_builder_pattern() { let provider = MockProvider::text("ok"); let agent = Agent::new(provider) @@ -138,10 +135,7 @@ async fn test_with_messages_builder() { ]; let provider = MockProvider::text("ok"); - let agent = Agent::new(provider) - .with_model("mock") - .with_api_key("test") - .with_messages(saved.clone()); + let agent = Agent::from_provider(provider, ModelConfig::mock()).with_messages(saved.clone()); assert_eq!(agent.messages().len(), 2); assert_eq!(*agent.messages(), saved[..]); @@ -150,10 +144,7 @@ async fn test_with_messages_builder() { #[tokio::test] async fn test_save_and_restore_messages() { let provider = MockProvider::text("Hello!"); - let mut agent = Agent::new(provider) - .with_system_prompt("test") - .with_model("mock") - .with_api_key("test"); + let mut agent = Agent::from_provider(provider, ModelConfig::mock()).with_system_prompt("test"); let mut rx = agent.prompt("Hi").await; while rx.recv().await.is_some() {} @@ -162,10 +153,8 @@ async fn test_save_and_restore_messages() { // Create a fresh agent and restore let provider2 = MockProvider::text("ok"); - let mut agent2 = Agent::new(provider2) - .with_system_prompt("test") - .with_model("mock") - .with_api_key("test"); + let mut agent2 = + Agent::from_provider(provider2, ModelConfig::mock()).with_system_prompt("test"); agent2 .restore_messages(&json) @@ -177,10 +166,8 @@ async fn test_save_and_restore_messages() { async fn test_agent_continues_after_restore() { // First agent: prompt → get response → save let provider1 = MockProvider::text("First response"); - let mut agent1 = Agent::new(provider1) - .with_system_prompt("test") - .with_model("mock") - .with_api_key("test"); + let mut agent1 = + Agent::from_provider(provider1, ModelConfig::mock()).with_system_prompt("test"); let mut rx = agent1.prompt("Hello").await; while rx.recv().await.is_some() {} @@ -190,10 +177,8 @@ async fn test_agent_continues_after_restore() { // Second agent: restore → prompt again // The MockProvider will receive the full restored history + new prompt let provider2 = MockProvider::text("Second response"); - let mut agent2 = Agent::new(provider2) - .with_system_prompt("test") - .with_model("mock") - .with_api_key("test"); + let mut agent2 = + Agent::from_provider(provider2, ModelConfig::mock()).with_system_prompt("test"); agent2.restore_messages(&json).expect("restore"); let mut rx = agent2.prompt("Follow up").await; @@ -215,10 +200,7 @@ async fn test_agent_continues_after_restore() { #[tokio::test] async fn test_prompt_with_sender_streams_events() { let provider = MockProvider::text("Hello!"); - let mut agent = Agent::new(provider) - .with_system_prompt("test") - .with_model("mock") - .with_api_key("test"); + let mut agent = Agent::from_provider(provider, ModelConfig::mock()).with_system_prompt("test"); let (tx, mut rx) = mpsc::unbounded_channel(); let event_count = Arc::new(AtomicUsize::new(0)); @@ -243,10 +225,7 @@ async fn test_prompt_with_sender_streams_events() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_prompt_with_sender_real_time_streaming() { let provider = MockProvider::text("Hello!"); - let mut agent = Agent::new(provider) - .with_system_prompt("test") - .with_model("mock") - .with_api_key("test"); + let mut agent = Agent::from_provider(provider, ModelConfig::mock()).with_system_prompt("test"); let (tx, mut rx) = mpsc::unbounded_channel(); let received_during = Arc::new(AtomicUsize::new(0)); @@ -270,10 +249,7 @@ async fn test_prompt_with_sender_real_time_streaming() { #[tokio::test] async fn test_prompt_messages_with_sender() { let provider = MockProvider::text("Response"); - let mut agent = Agent::new(provider) - .with_system_prompt("test") - .with_model("mock") - .with_api_key("test"); + let mut agent = Agent::from_provider(provider, ModelConfig::mock()).with_system_prompt("test"); let (tx, mut rx) = mpsc::unbounded_channel(); @@ -296,10 +272,7 @@ async fn test_prompt_messages_with_sender() { #[tokio::test] async fn test_continue_loop_with_sender() { let provider = MockProvider::text("Continued response"); - let mut agent = Agent::new(provider) - .with_system_prompt("test") - .with_model("mock") - .with_api_key("test"); + let mut agent = Agent::from_provider(provider, ModelConfig::mock()).with_system_prompt("test"); // First, add some messages to continue from (last must not be assistant) agent.append_message(AgentMessage::Llm(Message::user("Hello"))); @@ -363,10 +336,8 @@ async fn test_prompt_with_sender_tools_restored() { } let provider = MockProvider::text("Hello!"); - let mut agent = Agent::new(provider) + let mut agent = Agent::from_provider(provider, ModelConfig::mock()) .with_system_prompt("test") - .with_model("mock") - .with_api_key("test") .with_tools(vec![Box::new(DummyTool)]); let (tx, mut rx) = mpsc::unbounded_channel(); @@ -387,10 +358,7 @@ async fn test_prompt_with_sender_tools_restored() { #[tokio::test] async fn test_queue_inspection_and_take() { let provider = MockProvider::text("Hello!"); - let agent = Agent::new(provider) - .with_system_prompt("test") - .with_model("mock") - .with_api_key("test"); + let agent = Agent::from_provider(provider, ModelConfig::mock()).with_system_prompt("test"); assert_eq!(agent.steering_queue_len(), 0); assert!(agent.steering_queue_snapshot().is_empty()); @@ -464,7 +432,11 @@ fn some_usage() -> Usage { } } +// Covers session_cost_usd's "no ModelConfig at all" branch (early `?` return). +// Only the deprecated `Agent::new` (with no from_* config) leaves model_config +// unset, so this test keeps that API to preserve that coverage until 1.0. #[test] +#[allow(deprecated)] fn session_cost_usd_none_without_model_config() { let agent = Agent::new(MockProvider::text("x")).with_messages(vec![assistant_with_usage(some_usage())]); @@ -482,8 +454,7 @@ fn session_cost_usd_none_when_rates_unconfigured() { "m", "M", ); - let agent = Agent::new(MockProvider::text("x")) - .with_model_config(mc) + let agent = Agent::from_provider(MockProvider::text("x"), mc) .with_messages(vec![assistant_with_usage(some_usage())]); assert_eq!(agent.session_cost_usd(), None); } @@ -501,13 +472,11 @@ fn session_cost_usd_sums_assistant_turns_only() { mc.cost.output_per_million = 15.0; let expected_per_turn = mc.cost.cost_usd(&some_usage()); - let agent = Agent::new(MockProvider::text("x")) - .with_model_config(mc) - .with_messages(vec![ - AgentMessage::Llm(Message::user("hi")), - assistant_with_usage(some_usage()), - assistant_with_usage(some_usage()), - ]); + let agent = Agent::from_provider(MockProvider::text("x"), mc).with_messages(vec![ + AgentMessage::Llm(Message::user("hi")), + assistant_with_usage(some_usage()), + assistant_with_usage(some_usage()), + ]); let total = agent.session_cost_usd().expect("rates are configured"); assert!(total > 0.0); assert!((total - 2.0 * expected_per_turn).abs() < 1e-9); @@ -559,18 +528,19 @@ async fn test_explicit_api_key_wins_over_env() { // parallel test execution. std::env::set_var("ZAI_API_KEY", "env-key-should-lose"); let captured = Arc::new(std::sync::Mutex::new(String::new())); - let mut agent = Agent::new(KeyCapturingProvider { - captured: captured.clone(), - }) - .with_model("m") - .with_api_key("explicit-key") - .with_model_config(yoagent::provider::ModelConfig::custom( - yoagent::provider::ApiProtocol::OpenAiCompletions, - "zai", - "http://localhost:8080/v1", - "m", - "M", - )); + let mut agent = Agent::from_provider( + KeyCapturingProvider { + captured: captured.clone(), + }, + yoagent::provider::ModelConfig::custom( + yoagent::provider::ApiProtocol::OpenAiCompletions, + "zai", + "http://localhost:8080/v1", + "m", + "M", + ), + ) + .with_api_key("explicit-key"); run_one_prompt(&mut agent).await; assert_eq!(*captured.lock().unwrap(), "explicit-key"); } @@ -579,17 +549,18 @@ async fn test_explicit_api_key_wins_over_env() { async fn test_env_var_fallback_resolves_api_key() { std::env::set_var("CEREBRAS_API_KEY", "cerebras-env-key"); let captured = Arc::new(std::sync::Mutex::new(String::new())); - let mut agent = Agent::new(KeyCapturingProvider { - captured: captured.clone(), - }) - .with_model("m") - .with_model_config(yoagent::provider::ModelConfig::custom( - yoagent::provider::ApiProtocol::OpenAiCompletions, - "cerebras", - "http://localhost:8080/v1", - "m", - "M", - )); + let mut agent = Agent::from_provider( + KeyCapturingProvider { + captured: captured.clone(), + }, + yoagent::provider::ModelConfig::custom( + yoagent::provider::ApiProtocol::OpenAiCompletions, + "cerebras", + "http://localhost:8080/v1", + "m", + "M", + ), + ); run_one_prompt(&mut agent).await; assert_eq!(*captured.lock().unwrap(), "cerebras-env-key"); } diff --git a/tests/shared_state_test.rs b/tests/shared_state_test.rs index 3392e0f..3c02263 100644 --- a/tests/shared_state_test.rs +++ b/tests/shared_state_test.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use tokio_util::sync::CancellationToken; use yoagent::provider::mock::*; use yoagent::provider::MockProvider; +use yoagent::provider::ModelConfig; use yoagent::shared_state::SharedState; use yoagent::sub_agent::SubAgentTool; use yoagent::*; @@ -30,11 +31,9 @@ async fn test_sub_agent_reads_shared_state() { MockResponse::Text("The build failed with exit code 1".into()), ])); - let sub_agent = SubAgentTool::new("analyzer", sub_provider) + let sub_agent = SubAgentTool::from_provider("analyzer", sub_provider, ModelConfig::mock()) .with_description("Analyzes artifacts") .with_system_prompt("Analyze the artifact.") - .with_model("mock") - .with_api_key("test") .with_shared_state(state.clone()); let result = sub_agent @@ -80,11 +79,9 @@ async fn test_sub_agent_writes_shared_state() { MockResponse::Text("Done, wrote summary.".into()), ])); - let sub_agent = SubAgentTool::new("writer", sub_provider) + let sub_agent = SubAgentTool::from_provider("writer", sub_provider, ModelConfig::mock()) .with_description("Writes summaries") .with_system_prompt("Summarize findings.") - .with_model("mock") - .with_api_key("test") .with_shared_state(state.clone()); sub_agent @@ -145,16 +142,12 @@ async fn test_parallel_sub_agents_share_state() { MockResponse::Text("B done".into()), ])); - let agent_a = SubAgentTool::new("agent_a", provider_a) + let agent_a = SubAgentTool::from_provider("agent_a", provider_a, ModelConfig::mock()) .with_system_prompt("You are agent A.") - .with_model("mock") - .with_api_key("test") .with_shared_state(state.clone()); - let agent_b = SubAgentTool::new("agent_b", provider_b) + let agent_b = SubAgentTool::from_provider("agent_b", provider_b, ModelConfig::mock()) .with_system_prompt("You are agent B.") - .with_model("mock") - .with_api_key("test") .with_shared_state(state.clone()); let ctx = || ToolContext { @@ -186,10 +179,8 @@ async fn test_parallel_sub_agents_share_state() { async fn test_sub_agent_without_shared_state_unchanged() { let sub_provider = Arc::new(MockProvider::text("hello")); - let sub_agent = SubAgentTool::new("plain", sub_provider) - .with_system_prompt("You are plain.") - .with_model("mock") - .with_api_key("test"); + let sub_agent = SubAgentTool::from_provider("plain", sub_provider, ModelConfig::mock()) + .with_system_prompt("You are plain."); // No .with_shared_state() — existing behavior let result = sub_agent @@ -233,10 +224,8 @@ async fn test_shared_state_summary_in_system_prompt() { MockResponse::Text("Listed state".into()), ])); - let sub_agent = SubAgentTool::new("lister", sub_provider) + let sub_agent = SubAgentTool::from_provider("lister", sub_provider, ModelConfig::mock()) .with_system_prompt("List state.") - .with_model("mock") - .with_api_key("test") .with_shared_state(state); let result = sub_agent diff --git a/tests/sub_agent_test.rs b/tests/sub_agent_test.rs index 51039f0..b0162bf 100644 --- a/tests/sub_agent_test.rs +++ b/tests/sub_agent_test.rs @@ -6,6 +6,7 @@ use tokio_util::sync::CancellationToken; use yoagent::agent_loop::{agent_loop, AgentLoopConfig}; use yoagent::provider::mock::*; use yoagent::provider::MockProvider; +use yoagent::provider::ModelConfig; use yoagent::sub_agent::SubAgentTool; use yoagent::*; @@ -53,11 +54,9 @@ async fn test_sub_agent_basic() { // The sub-agent's mock provider returns a simple text response let sub_provider = Arc::new(MockProvider::text("Research result: Rust is great")); - let sub_agent = SubAgentTool::new("researcher", sub_provider) + let sub_agent = SubAgentTool::from_provider("researcher", sub_provider, ModelConfig::mock()) .with_description("Researches topics") - .with_system_prompt("You are a research assistant.") - .with_model("mock") - .with_api_key("test"); + .with_system_prompt("You are a research assistant."); // Execute the sub-agent tool directly let params = serde_json::json!({"task": "Tell me about Rust"}); @@ -141,11 +140,9 @@ async fn test_sub_agent_with_tools() { let echo_tool: Arc = Arc::new(EchoTool); - let sub_agent = SubAgentTool::new("echo_agent", sub_provider) + let sub_agent = SubAgentTool::from_provider("echo_agent", sub_provider, ModelConfig::mock()) .with_description("Agent that echoes") .with_system_prompt("Use the echo tool.") - .with_model("mock") - .with_api_key("test") .with_tools(vec![echo_tool]); let params = serde_json::json!({"task": "Echo hello"}); @@ -180,9 +177,8 @@ async fn test_sub_agent_cancellation() { // Sub-agent provider returns text, but we cancel before execution let sub_provider = Arc::new(MockProvider::text("Should not appear")); - let sub_agent = SubAgentTool::new("cancelled_agent", sub_provider) - .with_model("mock") - .with_api_key("test"); + let sub_agent = + SubAgentTool::from_provider("cancelled_agent", sub_provider, ModelConfig::mock()); let cancel = CancellationToken::new(); cancel.cancel(); // Cancel immediately @@ -237,9 +233,7 @@ async fn test_sub_agent_max_turns() { let echo_tool: Arc = Arc::new(EchoTool); - let sub_agent = SubAgentTool::new("limited_agent", sub_provider) - .with_model("mock") - .with_api_key("test") + let sub_agent = SubAgentTool::from_provider("limited_agent", sub_provider, ModelConfig::mock()) .with_tools(vec![echo_tool]) .with_max_turns(1); // Only 1 turn allowed @@ -316,25 +310,23 @@ async fn test_sub_agent_parallel() { } } - let sub_a = SubAgentTool::new( + let sub_a = SubAgentTool::from_provider( "agent_a", Arc::new(SlowProvider { delay_ms: 50, text: "Result A".into(), }), - ) - .with_model("slow") - .with_api_key("test"); + ModelConfig::mock(), + ); - let sub_b = SubAgentTool::new( + let sub_b = SubAgentTool::from_provider( "agent_b", Arc::new(SlowProvider { delay_ms: 50, text: "Result B".into(), }), - ) - .with_model("slow") - .with_api_key("test"); + ModelConfig::mock(), + ); // Parent provider: first call triggers both sub-agents, second returns final text let parent_provider = MockProvider::new(vec![ @@ -394,9 +386,8 @@ async fn test_sub_agent_parallel() { async fn test_sub_agent_event_forwarding() { let sub_provider = Arc::new(MockProvider::text("Sub-agent done")); - let sub_agent = SubAgentTool::new("streaming_agent", sub_provider) - .with_model("mock") - .with_api_key("test"); + let sub_agent = + SubAgentTool::from_provider("streaming_agent", sub_provider, ModelConfig::mock()); let params = serde_json::json!({"task": "Do work"}); @@ -452,9 +443,7 @@ async fn test_sub_agent_event_forwarding() { async fn test_sub_agent_missing_task_parameter() { let sub_provider = Arc::new(MockProvider::text("Should not run")); - let sub_agent = SubAgentTool::new("test_agent", sub_provider) - .with_model("mock") - .with_api_key("test"); + let sub_agent = SubAgentTool::from_provider("test_agent", sub_provider, ModelConfig::mock()); let params = serde_json::json!({}); // Missing "task" @@ -586,10 +575,8 @@ async fn test_sub_agent_with_skills() { assert_eq!(skills.len(), 1, "expected the research skill to load"); let prompt = capture_system_prompt(|provider| { - SubAgentTool::new("researcher", provider) + SubAgentTool::from_provider("researcher", provider, ModelConfig::mock()) .with_system_prompt("You are a research assistant.") - .with_model("mock") - .with_api_key("test") .with_skills(skills) }) .await; @@ -617,10 +604,7 @@ async fn test_sub_agent_with_skills_empty_base_prompt() { let prompt = capture_system_prompt(|provider| { // No with_system_prompt() call — base prompt is empty. - SubAgentTool::new("researcher", provider) - .with_model("mock") - .with_api_key("test") - .with_skills(skills) + SubAgentTool::from_provider("researcher", provider, ModelConfig::mock()).with_skills(skills) }) .await; @@ -634,10 +618,8 @@ async fn test_sub_agent_with_skills_empty_base_prompt() { async fn test_sub_agent_with_empty_skillset_is_noop() { // An empty SkillSet must not alter the system prompt (no trailing "\n\n"). let prompt = capture_system_prompt(|provider| { - SubAgentTool::new("researcher", provider) + SubAgentTool::from_provider("researcher", provider, ModelConfig::mock()) .with_system_prompt("Base prompt.") - .with_model("mock") - .with_api_key("test") .with_skills(yoagent::skills::SkillSet::empty()) }) .await; @@ -654,10 +636,8 @@ async fn test_sub_agent_skills_before_shared_state() { let state = SharedState::new(); let prompt = capture_system_prompt(|provider| { - SubAgentTool::new("researcher", provider) + SubAgentTool::from_provider("researcher", provider, ModelConfig::mock()) .with_system_prompt("Base prompt.") - .with_model("mock") - .with_api_key("test") .with_skills(skills) .with_shared_state(state) }) @@ -684,10 +664,8 @@ async fn test_sub_agent_in_parent_loop() { // Parent calls sub-agent, sub-agent returns text, parent summarizes let sub_provider = Arc::new(MockProvider::text("42 is the answer")); - let sub_agent = SubAgentTool::new("calculator", sub_provider) - .with_description("Calculates things") - .with_model("mock") - .with_api_key("test"); + let sub_agent = SubAgentTool::from_provider("calculator", sub_provider, ModelConfig::mock()) + .with_description("Calculates things"); let parent_provider = MockProvider::new(vec![ MockResponse::ToolCalls(vec![MockToolCall { @@ -796,14 +774,13 @@ async fn run_sub_agent(tool: &SubAgentTool) { #[tokio::test] async fn test_sub_agent_temperature_reaches_provider() { let captured = Arc::new(std::sync::Mutex::new((String::new(), None))); - let tool = SubAgentTool::new( + let tool = SubAgentTool::from_provider( "cfg", Arc::new(StreamConfigCapture { captured: captured.clone(), }), + ModelConfig::mock(), ) - .with_model("mock") - .with_api_key("k") .with_temperature(0.3); run_sub_agent(&tool).await; @@ -816,20 +793,19 @@ async fn test_sub_agent_env_key_fallback() { // parallel execution. std::env::set_var("MINIMAX_API_KEY", "minimax-env-key"); let captured = Arc::new(std::sync::Mutex::new((String::new(), None))); - let tool = SubAgentTool::new( + let tool = SubAgentTool::from_provider( "cfg", Arc::new(StreamConfigCapture { captured: captured.clone(), }), - ) - .with_model("m") - .with_model_config(yoagent::provider::ModelConfig::custom( - yoagent::provider::ApiProtocol::OpenAiCompletions, - "minimax", - "http://localhost:8080/v1", - "m", - "M", - )); + yoagent::provider::ModelConfig::custom( + yoagent::provider::ApiProtocol::OpenAiCompletions, + "minimax", + "http://localhost:8080/v1", + "m", + "M", + ), + ); run_sub_agent(&tool).await; assert_eq!(captured.lock().unwrap().0, "minimax-env-key");