diff --git a/examples/evolving-trader/src/main.rs b/examples/evolving-trader/src/main.rs index 836319c..d24b0d4 100644 --- a/examples/evolving-trader/src/main.rs +++ b/examples/evolving-trader/src/main.rs @@ -693,7 +693,7 @@ async fn agent(host_crate: &str) -> symbiont::Result { let agent = Agent::new( symbiont::agent_builder( Some(host_crate), - DocMode::default(), + DocMode::IndexAndTools, &base_url, &api_key, &model, diff --git a/examples/fractal-studio/src/main.rs b/examples/fractal-studio/src/main.rs index bcdc441..9dc3e4f 100644 --- a/examples/fractal-studio/src/main.rs +++ b/examples/fractal-studio/src/main.rs @@ -48,6 +48,7 @@ use rayon::prelude::*; use symbiont::{ DocMode, Runtime, + ThinkingLevel, }; use tracing::{ info, @@ -770,7 +771,7 @@ fn main() -> eframe::Result<()> { None, DocMode::default(), &model, - false, + ThinkingLevel::Medium, )) .expect("can initialize the agent; check the API_KEY and BASE_URL env vars"); let tokio_handle = tokio_rt.handle().clone(); diff --git a/examples/struct-support/src/main.rs b/examples/struct-support/src/main.rs index 4ba7098..2a91c7b 100644 --- a/examples/struct-support/src/main.rs +++ b/examples/struct-support/src/main.rs @@ -36,7 +36,7 @@ async fn main() -> symbiont::Result<()> { let doc_crate = Some(host_crate); // Document the host crate API for the agent. let model = std::env::var("MODEL").expect("the MODEL env var names the model slug"); - let agent = symbiont::agent_from_env(doc_crate, DocMode::default(), &model, false).await?; + let agent = symbiont::agent_from_env(doc_crate, DocMode::IndexAndTools, &model, false).await?; let base_prompt = format!( "Give an implementation for this evolvable function:\n diff --git a/symbiont/src/error.rs b/symbiont/src/error.rs index 05707b2..ecfd709 100644 --- a/symbiont/src/error.rs +++ b/symbiont/src/error.rs @@ -90,6 +90,9 @@ pub enum Error { #[error(transparent)] Fmt(#[from] std::fmt::Error), + + #[error("A DocMode with tools requires an `opt_crate_name` to be set.")] + InvalidDocMode, } /// Result type alias for symbiont operations. diff --git a/symbiont/src/inference/agent_builder.rs b/symbiont/src/inference/agent_builder.rs index bee837b..867b4c5 100644 --- a/symbiont/src/inference/agent_builder.rs +++ b/symbiont/src/inference/agent_builder.rs @@ -26,6 +26,7 @@ use crate::{ ApiIndexTool, DocIndex, DocMode, + Error, MeteredHttpClient, Result, ThinkingLevel, @@ -120,7 +121,7 @@ pub const DOC_TOOLS_MAX_TURNS: usize = 8; /// # Arguments: /// - `opt_crate_name`: The crate whose API the evolved code can use, usually /// `Some(env!("CARGO_PKG_NAME"))`. With `None`, no host API is documented -/// and `doc_mode` has no effect. +/// and `doc_mode` must be `DocMode::Inline`. /// - `doc_mode`: How the agent gets the host API documentation: inline in /// the system prompt, or on demand through the `api_index` and `api_doc` /// tools. @@ -131,6 +132,12 @@ pub const DOC_TOOLS_MAX_TURNS: usize = 8; /// (vLLM, llama-server, OpenRouter, OpenAI, etc.). Can also be passed as `bool` (`false` -> `Disabled`, `true` -> `Medium`). /// Keep this [`ThinkingLevel::Disabled`] for thinking models (e.g. Qwen3, DeepSeek) in latency-sensitive loops. /// +/// # Errors +/// +/// Returns [`Error::InvalidDocMode`] if `doc_mode` registers tools but +/// `opt_crate_name` is `None`. Also returns errors from building the +/// inference client or the host API documentation. +/// pub async fn agent_builder( opt_crate_name: Option<&str>, doc_mode: DocMode, @@ -171,6 +178,7 @@ pub async fn agent_builder( .tool(ApiDocTool::new(index)) .default_max_turns(DOC_TOOLS_MAX_TURNS) } + (None, true) => return Err(Error::InvalidDocMode), _ => builder.dynamic_tools(Vec::new()), }; Ok(builder) @@ -207,13 +215,19 @@ pub async fn agent_from_env( /// # Arguments: /// - `opt_crate_name`: The crate whose API the evolved code can use, usually /// `Some(env!("CARGO_PKG_NAME"))`. With `None`, no host API is documented -/// and `doc_mode` has no effect. +/// and `doc_mode` must be `DocMode::Inline`. /// - `doc_mode`: How the agent gets the host API documentation. See [`agent_builder`]. /// - `base_url`: The inference endpoint for `/v1/chat/completions` based requests. /// - `api_key`: The API key for authenticating the requests, if any. Can be empty. /// - `model`: The model slug served at `base_url`. /// - `thinking`: The [`ThinkingLevel`] configuring reasoning effort. See [`agent_builder`]. /// +/// # Errors +/// +/// Returns the errors that [`agent_builder`] returns, in particular +/// [`Error::InvalidDocMode`] if `doc_mode` registers tools but +/// `opt_crate_name` is `None`. +/// pub async fn init_agent( opt_crate_name: Option<&str>, doc_mode: DocMode, @@ -239,7 +253,7 @@ mod tests { async fn init_agent_carries_the_base_url_as_provider() { let agent = init_agent( None, - DocMode::default(), + DocMode::Inline, "http://127.0.0.1:8321/v1", "", "model", @@ -249,4 +263,28 @@ mod tests { .expect("building a local agent needs no network"); assert_eq!(agent.provider(), "http://127.0.0.1:8321/v1"); } + + // ponytail: building the real reqwest client initializes aws-lc-rs via FFI, + // which Miri cannot execute; runs fine under a normal test. + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn agent_builder_rejects_tool_doc_modes_without_crate() { + for doc_mode in [DocMode::IndexAndTools, DocMode::Tools] { + assert!( + matches!( + agent_builder( + None, + doc_mode, + "http://127.0.0.1:8321/v1", + "", + "model", + ThinkingLevel::Disabled, + ) + .await, + Err(Error::InvalidDocMode) + ), + "tool doc modes need a crate, got doc_mode {doc_mode:?}" + ); + } + } } diff --git a/symbiont/src/system_prompt.rs b/symbiont/src/system_prompt.rs index f48bb86..0ca2bed 100644 --- a/symbiont/src/system_prompt.rs +++ b/symbiont/src/system_prompt.rs @@ -4,6 +4,7 @@ use tracing::info; use crate::{ DocIndex, DocIndexError, + Error, Result, doc_string::write_prelude_doc_string, }; @@ -182,11 +183,11 @@ pub enum DocMode { /// The system prompt contains the full API synopsis. The prompt grows /// with the size of the host API, and every inference request sends it /// again. + #[default] Inline, /// The system prompt contains the compact index of the prelude. The /// `api_index` and `api_doc` tools give the agent the full definitions /// on demand. - #[default] IndexAndTools, /// The system prompt contains no API content. The agent explores the API /// with the `api_index` and `api_doc` tools. @@ -229,13 +230,14 @@ impl DocMode { /// /// - `opt_crate_name`: The crate to document, usually /// `Some(env!("CARGO_PKG_NAME"))`. With `None`, no host API is documented -/// and `doc_mode` has no effect. +/// and `doc_mode` must be `DocMode::Inline`. /// - `doc_mode`: How the prompt carries the host API documentation. /// /// # Errors /// -/// Returns an error if the runtime cannot build or parse the documentation -/// of the host crate. +/// Returns [`Error::InvalidDocMode`] if `doc_mode` registers tools but +/// `opt_crate_name` is `None`. Otherwise, returns an error if the runtime +/// cannot build or parse the documentation of the host crate. pub async fn system_prompt(opt_crate_name: Option<&str>, doc_mode: DocMode) -> Result { let mut prompt = BASE_PROMPT.to_string(); match (opt_crate_name, doc_mode) { @@ -256,7 +258,8 @@ pub async fn system_prompt(opt_crate_name: Option<&str>, doc_mode: DocMode) -> R } } (Some(_), DocMode::Tools) => prompt.push_str(TOOL_DOC_SECTION), - (None, _) => prompt.push_str(INLINE_DOC_SECTION), + (None, DocMode::Inline) => prompt.push_str(INLINE_DOC_SECTION), + (None, _) => return Err(Error::InvalidDocMode), } info!("system_prompt: {}", prompt.green()); @@ -277,14 +280,21 @@ mod tests { use super::*; #[tokio::test(flavor = "current_thread")] - async fn system_prompt_without_crate_ignores_doc_mode() { - for doc_mode in [DocMode::Inline, DocMode::IndexAndTools, DocMode::Tools] { - let prompt = system_prompt(None, doc_mode) - .await - .expect("no docs to build"); - assert!(prompt.contains("# Host API documentation")); - assert!(prompt.contains("only `std` is available")); - assert!(!prompt.contains("api_doc")); + async fn system_prompt_without_crate_requires_inline() { + let prompt = system_prompt(None, DocMode::Inline) + .await + .expect("no docs to build"); + assert!(prompt.contains("# Host API documentation")); + assert!(prompt.contains("only `std` is available")); + assert!(!prompt.contains("api_doc")); + for doc_mode in [DocMode::IndexAndTools, DocMode::Tools] { + assert!( + matches!( + system_prompt(None, doc_mode).await, + Err(Error::InvalidDocMode) + ), + "tool doc modes need a crate, got doc_mode {doc_mode:?}" + ); } }