Skip to main content

bamboo_sdk/agent/
builder.rs

1//! Ergonomic [`AgentBuilder`] for the root SDK facade.
2//!
3//! This wraps [`bamboo_engine::AgentBuilder`] with a concise, one-liner facade:
4//! the caller supplies their own instruction (a system-prompt fragment) plus a
5//! model and optional tool policy, and the engine dynamically assembles the
6//! complete system prompt (tool guides, runtime context, …) around it at run
7//! time.
8//!
9//! ```rust,ignore
10//! let agent = Agent::builder()
11//!     .model("claude-sonnet-4-6")
12//!     .instruction("You help users research topics thoroughly.")
13//!     .with_defaults_for_data_dir(data_dir).await?
14//!     .build()?;
15//! ```
16//!
17//! `.with_defaults_for_data_dir` assembles the eight runtime dependencies from
18//! the **infrastructure / engine / tools** crates only — `bamboo-server` is
19//! never pulled into the builder path (reverse-dep risk register §6).
20
21use std::sync::Arc;
22
23use std::path::PathBuf;
24use tokio::sync::RwLock;
25
26use bamboo_agent_core::tools::{Tool, ToolExecutor};
27use bamboo_engine::AgentBuilder as EngineAgentBuilder;
28use bamboo_llm::{create_provider_with_dir, Config, LLMProvider};
29use bamboo_mcp::executor::{CompositeToolExecutor, McpToolExecutor};
30use bamboo_mcp::manager::McpServerManager;
31use bamboo_mcp::McpServerConfig;
32use bamboo_metrics::{MetricsCollector, SqliteMetricsStorage};
33use bamboo_skills::{SkillManager, SkillStoreConfig};
34use bamboo_storage::{LockedSessionStore, SessionStoreV2};
35use bamboo_tools::permission::PermissionChecker;
36use bamboo_tools::ToolRegistry;
37
38use super::error::SdkError;
39use super::Agent;
40
41/// Default metrics retention window (days), mirroring `MetricsService::new`.
42const DEFAULT_METRICS_RETENTION_DAYS: u32 = 90;
43
44/// Ergonomic builder for [`Agent`].
45///
46/// Holds the configured instruction (system-prompt fragment), tool set, model,
47/// and api key alongside the wrapped engine builder. Call
48/// [`with_defaults_for_data_dir`](Self::with_defaults_for_data_dir) to assemble
49/// the runtime dependencies, then [`build`](Self::build).
50pub struct AgentBuilder {
51    inner: EngineAgentBuilder,
52
53    /// Caller-supplied instruction (system-prompt fragment), injected into the
54    /// session at `run` time; the engine assembles the full prompt around it.
55    system_prompt: Option<String>,
56    /// The agent's tool set — built-ins (via [`BuiltinTool::tool`](super::BuiltinTool::tool))
57    /// and/or custom `impl Tool`s. Empty means "all default built-in tools".
58    tools: Vec<Arc<dyn Tool>>,
59    /// Primary model override applied to the session at `run` time.
60    model: Option<String>,
61    /// Provider to select (e.g. `anthropic`, `openai`, `gemini`, `copilot`,
62    /// `bodhi`) in `with_defaults_for_data_dir`, overriding `config.json`'s
63    /// `provider`. `None` keeps the configured default.
64    provider_name: Option<String>,
65    /// API key applied to the active provider's config before provider creation.
66    api_key: Option<String>,
67    /// MCP servers to connect and merge into the tool surface in
68    /// `with_defaults_for_data_dir`. See [`mcp_server`](Self::mcp_server).
69    mcp_servers: Vec<McpServerConfig>,
70    /// Permission checker applied to the built-in tool executor assembled by
71    /// `with_defaults_for_data_dir`. `None` (the default) means no permission
72    /// gating at all — every tool call runs unprompted. See
73    /// [`permission_checker`](Self::permission_checker).
74    permission_checker: Option<Arc<dyn PermissionChecker>>,
75    /// Concrete session-index handle assembled by `with_defaults_for_data_dir`
76    /// (internal — not settable directly). Carried onto [`Agent`] to back the
77    /// session-listing ergonomics ([`Agent::list_sessions`](super::Agent::list_sessions)),
78    /// which need the concrete `SessionStoreV2` rather than the type-erased
79    /// `Arc<dyn Storage>` the engine builder takes.
80    session_store: Option<Arc<SessionStoreV2>>,
81}
82
83impl AgentBuilder {
84    /// Create an empty ergonomic builder.
85    pub fn new() -> Self {
86        Self {
87            inner: EngineAgentBuilder::new(),
88            system_prompt: None,
89            tools: Vec::new(),
90            model: None,
91            provider_name: None,
92            api_key: None,
93            mcp_servers: Vec::new(),
94            permission_checker: None,
95            session_store: None,
96        }
97    }
98
99    // -- Configuration ------------------------------------------------------
100
101    /// Set the primary model.
102    pub fn model(mut self, model: impl Into<String>) -> Self {
103        self.model = Some(model.into());
104        self
105    }
106
107    /// Select the provider by name (`anthropic`, `openai`, `gemini`, `copilot`,
108    /// `bodhi`) for [`with_defaults_for_data_dir`](Self::with_defaults_for_data_dir),
109    /// overriding `config.json`'s `provider`. A following [`api_key`](Self::api_key)
110    /// applies to *this* provider. The name is lower-cased (config matching is
111    /// case-sensitive).
112    ///
113    /// Note: this drives the *eager* provider creation inside
114    /// `with_defaults_for_data_dir` and can fail there (e.g. missing key). A later
115    /// [`provider`](Self::provider) injection replaces the created provider but
116    /// does NOT skip creation — so if you inject your own provider, either don't
117    /// set `provider_name`, or ensure the named provider can still be constructed.
118    pub fn provider_name(mut self, provider: impl Into<String>) -> Self {
119        self.provider_name = Some(provider.into().trim().to_ascii_lowercase());
120        self
121    }
122
123    /// Set the instruction — the caller's portion of the system prompt. The
124    /// engine assembles the complete prompt (tool guides, runtime context, …)
125    /// around it at run time.
126    pub fn instruction(mut self, instruction: impl Into<String>) -> Self {
127        self.system_prompt = Some(instruction.into());
128        self
129    }
130
131    /// Set the agent's tool set — the actual tools it may use, as
132    /// `Arc<dyn Tool>`. Built-ins come from the
133    /// [`BuiltinTool`](super::BuiltinTool) catalog via
134    /// [`BuiltinTool::tool`](super::BuiltinTool::tool); custom tools are any
135    /// `impl Tool` wrapped in an `Arc`. Replaces any previous selection.
136    ///
137    /// ```rust,ignore
138    /// agent.tools([BuiltinTool::WebSearch.tool(), BuiltinTool::Read.tool()]);
139    /// ```
140    ///
141    /// Leaving this unset uses the full default built-in tool surface.
142    pub fn tools<I>(mut self, tools: I) -> Self
143    where
144        I: IntoIterator<Item = Arc<dyn Tool>>,
145    {
146        self.tools = tools.into_iter().collect();
147        self
148    }
149
150    /// Add a single custom tool (anything implementing
151    /// [`Tool`](bamboo_agent_core::tools::Tool)) to the tool set.
152    pub fn tool<T: Tool + 'static>(mut self, tool: T) -> Self {
153        self.tools.push(Arc::new(tool));
154        self
155    }
156
157    /// Add a single pre-built shared tool — e.g. `BuiltinTool::Read.tool()` or a
158    /// shared custom tool — to the tool set.
159    pub fn tool_shared(mut self, tool: Arc<dyn Tool>) -> Self {
160        self.tools.push(tool);
161        self
162    }
163
164    /// Set the API key applied to the active provider's config in
165    /// [`with_defaults_for_data_dir`](Self::with_defaults_for_data_dir).
166    pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
167        self.api_key = Some(api_key.into());
168        self
169    }
170
171    /// Connect an MCP server and merge its tools into the agent's tool surface.
172    ///
173    /// Only takes effect via
174    /// [`with_defaults_for_data_dir`](Self::with_defaults_for_data_dir), which
175    /// starts every configured server (in call order) and composes their tools
176    /// with the built-in surface via
177    /// [`CompositeToolExecutor`](bamboo_mcp::executor::CompositeToolExecutor) —
178    /// built-ins are tried first, falling back to MCP on `NotFound`. Each
179    /// server's `initialize` `instructions` (if any) are folded into the tool
180    /// guidance the engine injects into the system prompt automatically (no
181    /// extra wiring needed).
182    ///
183    /// A later [`tools`](Self::tools)/[`tool`](Self::tool) selection REPLACES
184    /// the whole assembled executor at [`build`](Self::build) time (existing
185    /// behavior, unchanged by this method) — so an explicit tool selection
186    /// currently excludes MCP tools. Select tools via `allowed_tools` on the
187    /// server config instead if you need to restrict without losing MCP.
188    ///
189    /// ```rust,ignore
190    /// use bamboo_sdk::agent::McpServerConfig;
191    /// use bamboo_mcp::{StdioConfig, TransportConfig};
192    ///
193    /// let agent = Agent::builder()
194    ///     .model("claude-sonnet-4-6")
195    ///     .mcp_server(McpServerConfig {
196    ///         id: "fs".into(),
197    ///         name: Some("filesystem".into()),
198    ///         enabled: true,
199    ///         transport: TransportConfig::Stdio(StdioConfig {
200    ///             command: "npx".into(),
201    ///             args: vec!["-y".into(), "@modelcontextprotocol/server-filesystem".into()],
202    ///             cwd: None,
203    ///             env: Default::default(),
204    ///             env_encrypted: Default::default(),
205    ///             startup_timeout_ms: 20_000,
206    ///         }),
207    ///         request_timeout_ms: 60_000,
208    ///         healthcheck_interval_ms: 30_000,
209    ///         reconnect: Default::default(),
210    ///         allowed_tools: Vec::new(),
211    ///         denied_tools: Vec::new(),
212    ///     })
213    ///     .with_defaults_for_data_dir(data_dir).await?
214    ///     .build()?;
215    /// ```
216    pub fn mcp_server(mut self, config: McpServerConfig) -> Self {
217        self.mcp_servers.push(config);
218        self
219    }
220
221    /// Connect multiple MCP servers. See [`mcp_server`](Self::mcp_server).
222    pub fn mcp_servers<I>(mut self, configs: I) -> Self
223    where
224        I: IntoIterator<Item = McpServerConfig>,
225    {
226        self.mcp_servers.extend(configs);
227        self
228    }
229
230    /// Gate the built-in tool executor behind a permission checker (e.g.
231    /// `bamboo_tools::permission::ConfigPermissionChecker`).
232    ///
233    /// Only takes effect via
234    /// [`with_defaults_for_data_dir`](Self::with_defaults_for_data_dir). The
235    /// SDK's historical default (no checker configured) is **bypass
236    /// everything** — no tool call is ever gated — so this is purely opt-in;
237    /// see [`bypass_permissions`](Self::bypass_permissions) to make that intent
238    /// explicit at the call site.
239    ///
240    /// Once gated, a tool call that needs approval suspends the run (a
241    /// `NeedClarification`/`ToolApprovalRequested` event, session
242    /// `pending_question` set) exactly like a `conclusion_with_options`
243    /// clarification — resolve it with [`Agent::answer`](super::Agent::answer).
244    pub fn permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
245        self.permission_checker = Some(checker);
246        self
247    }
248
249    /// Explicitly request the SDK's default: no permission checker, so every
250    /// tool call runs unprompted. A no-op relative to never calling
251    /// [`permission_checker`](Self::permission_checker) — provided so callers
252    /// can say what they mean instead of relying on silent default behavior.
253    pub fn bypass_permissions(mut self) -> Self {
254        self.permission_checker = None;
255        self
256    }
257
258    // -- Explicit dependency injection (passthrough) ------------------------
259
260    /// Inject a pre-built LLM provider, bypassing config-driven creation.
261    pub fn provider(mut self, provider: Arc<dyn LLMProvider>) -> Self {
262        self.inner = self.inner.provider(provider);
263        self
264    }
265
266    /// Inject a pre-built default tool executor.
267    pub fn default_tools(mut self, tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor>) -> Self {
268        self.inner = self.inner.default_tools(tools);
269        self
270    }
271
272    /// Inject a shared config handle.
273    pub fn config(mut self, config: Arc<RwLock<Config>>) -> Self {
274        self.inner = self.inner.config(config);
275        self
276    }
277
278    // -- Default dependency assembly ---------------------------------------
279
280    /// Assemble the eight runtime dependencies rooted at `data_dir`, using only
281    /// the infrastructure / engine / tools layers (never `bamboo-server`):
282    ///
283    /// 1. `Config::from_data_dir(data_dir)` (with `api_key` applied if set)
284    /// 2. `SessionStoreV2` → `storage` + `attachment_reader`
285    /// 3. `LockedSessionStore` → `persistence`
286    /// 4. `SkillManager` (+ `initialize`)
287    /// 5. `MetricsCollector::spawn(SqliteMetricsStorage)`
288    /// 6. provider via `create_provider_with_dir`
289    /// 7. `BuiltinToolExecutor::new_with_config` → `default_tools`
290    ///
291    /// The engine builder is last-write-wins, so this method does NOT preserve
292    /// dependencies set before it. Call `with_defaults_for_data_dir` FIRST, then
293    /// override individual dependencies (e.g. [`provider`](Self::provider)) AFTER
294    /// it to make those overrides take precedence.
295    ///
296    /// # Precondition
297    ///
298    /// `<data_dir>/config.json` must define the active provider with a non-empty
299    /// `api_key` (the same config `bamboo serve` reads). A fresh data dir with no
300    /// `config.json` defaults to the `anthropic` provider with no key, so step 6
301    /// (`create_provider_with_dir`) returns [`SdkError::ProviderInit`].
302    /// The `copilot` provider is the only one that can authenticate keyless (via
303    /// its cached OAuth token). Set the key via the config file, or pass it on the
304    /// builder with [`api_key`](Self::api_key) **before** calling this method.
305    ///
306    /// If [`mcp_server`](Self::mcp_server)/[`mcp_servers`](Self::mcp_servers) were
307    /// configured, each server is connected here (in call order) and its tools
308    /// merged into the built-in tool surface; a connection failure fails the
309    /// whole call with [`SdkError::McpServerStart`]. If
310    /// [`permission_checker`](Self::permission_checker) was configured, it gates
311    /// the built-in tool executor from this point on.
312    pub async fn with_defaults_for_data_dir(mut self, data_dir: PathBuf) -> Result<Self, SdkError> {
313        // 1. Config.
314        let mut config = Config::from_data_dir(Some(data_dir.clone()));
315        // Select the provider first, so `api_key` and provider creation both act
316        // on the chosen provider rather than config.json's default.
317        if let Some(provider) = self.provider_name.clone() {
318            config.provider = provider;
319        }
320        if let Some(api_key) = self.api_key.clone() {
321            apply_api_key(&mut config, &api_key);
322        }
323
324        // 6. Provider (created before config is moved into the shared lock).
325        let provider = create_provider_with_dir(&config, data_dir.clone())
326            .await
327            .map_err(|e| SdkError::ProviderInit(e.to_string()))?;
328
329        // 7. Default tools (builtin + config-aware), optionally gated by a
330        // permission checker and merged with any configured MCP servers.
331        let config = Arc::new(RwLock::new(config));
332        let builtin_tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor> =
333            match self.permission_checker.clone() {
334                Some(checker) => Arc::new(
335                    bamboo_tools::BuiltinToolExecutor::new_with_config_and_permissions(
336                        config.clone(),
337                        checker,
338                    ),
339                ),
340                None => Arc::new(bamboo_tools::BuiltinToolExecutor::new_with_config(
341                    config.clone(),
342                )),
343            };
344        let default_tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor> =
345            if self.mcp_servers.is_empty() {
346                builtin_tools
347            } else {
348                let mcp_manager = Arc::new(McpServerManager::new_with_config(config.clone()));
349                for server_config in &self.mcp_servers {
350                    let server_id = server_config.id.clone();
351                    mcp_manager
352                        .start_server(server_config.clone())
353                        .await
354                        .map_err(|source| SdkError::McpServerStart { server_id, source })?;
355                }
356                let mcp_tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor> = Arc::new(
357                    McpToolExecutor::new(mcp_manager.clone(), mcp_manager.tool_index()),
358                );
359                Arc::new(CompositeToolExecutor::new(builtin_tools, mcp_tools))
360            };
361
362        // 2/3. Storage + persistence + attachment reader.
363        let store = Arc::new(
364            SessionStoreV2::new(data_dir.clone())
365                .await
366                .map_err(|e| SdkError::StoreInit(e.to_string()))?,
367        );
368        let persistence = Arc::new(LockedSessionStore::new(store.clone()));
369
370        // 4. Skill manager.
371        let skill_manager = Arc::new(SkillManager::with_config(SkillStoreConfig {
372            skills_dir: data_dir.join("skills"),
373            project_dir: std::env::current_dir().ok(),
374            active_mode: None,
375        }));
376        skill_manager
377            .initialize()
378            .await
379            .map_err(|e| SdkError::SkillInit(e.to_string()))?;
380
381        // 5. Metrics collector.
382        let metrics_storage: Arc<dyn bamboo_metrics::storage::MetricsStorage> =
383            Arc::new(SqliteMetricsStorage::new(data_dir.join("metrics.db")));
384        let metrics_collector =
385            MetricsCollector::spawn(metrics_storage, DEFAULT_METRICS_RETENTION_DAYS);
386
387        self.session_store = Some(store.clone());
388        self.inner = self
389            .inner
390            .storage(store.clone())
391            .persistence(persistence)
392            .attachment_reader(store)
393            .skill_manager(skill_manager)
394            .metrics_collector(metrics_collector)
395            .config(config)
396            .provider(provider)
397            .default_tools(default_tools);
398
399        Ok(self)
400    }
401
402    /// Finalize into an [`Agent`].
403    ///
404    /// If a tool set was configured via [`tools`](Self::tools) / [`tool`](Self::tool),
405    /// the agent's default tool executor is built from exactly those tools, so
406    /// the advertised tool surface is precisely the caller's selection (this
407    /// REPLACES any MCP composition from
408    /// [`mcp_server`](Self::mcp_server)/[`mcp_servers`](Self::mcp_servers) —
409    /// existing behavior, unchanged). With no selection, the full default
410    /// built-in (+ MCP, if configured) surface is used. The configured
411    /// `instruction` and `model` are carried onto the `Agent` for
412    /// [`Agent::run`](super::Agent::run).
413    pub fn build(mut self) -> Result<Agent, SdkError> {
414        if !self.tools.is_empty() {
415            let registry = ToolRegistry::new();
416            for tool in &self.tools {
417                let _ = registry.register_shared(tool.clone());
418            }
419            let executor: Arc<dyn ToolExecutor> =
420                Arc::new(bamboo_tools::BuiltinToolExecutor::with_registry(registry));
421            self.inner = self.inner.default_tools(executor);
422        }
423
424        let runtime = self
425            .inner
426            .build()
427            .map_err(|e| SdkError::Build(e.to_string()))?;
428        Ok(Agent::from_runtime_with_config(
429            runtime,
430            self.system_prompt,
431            self.model,
432            self.session_store,
433            self.permission_checker,
434        ))
435    }
436}
437
438impl Default for AgentBuilder {
439    fn default() -> Self {
440        Self::new()
441    }
442}
443
444/// Apply `api_key` to the active provider's in-memory config slot.
445///
446/// If the provider stanza already exists, its key is overwritten. If it is
447/// absent (the common default-config / no-`config.json` case), a minimal stanza
448/// is **fabricated** from `{"api_key": …}` — every other field is serde-default
449/// (all `Option`/`Vec`/`flatten`), so `.api_key("sk-…")` alone is enough to make
450/// a fresh data dir usable. Only the keyed providers (`openai` / `anthropic` /
451/// `gemini`) are fabricated; other providers (e.g. `copilot`, which authenticates
452/// via cached OAuth rather than a plain key) fall through to a warning.
453fn apply_api_key(config: &mut Config, api_key: &str) {
454    // A minimal `{"api_key": …}` stanza; every other provider-config field
455    // deserializes to its serde default, so the target type is inferred from
456    // the assignment below (no `serde` trait import needed).
457    let stanza = || serde_json::json!({ "api_key": api_key });
458
459    let provider = config.provider.clone();
460    let providers = config.providers_mut();
461    let applied = match provider.as_str() {
462        "openai" => match providers.openai.as_mut() {
463            Some(c) => {
464                c.api_key = api_key.to_string();
465                true
466            }
467            None => {
468                providers.openai = serde_json::from_value(stanza()).ok();
469                providers.openai.is_some()
470            }
471        },
472        "anthropic" => match providers.anthropic.as_mut() {
473            Some(c) => {
474                c.api_key = api_key.to_string();
475                true
476            }
477            None => {
478                providers.anthropic = serde_json::from_value(stanza()).ok();
479                providers.anthropic.is_some()
480            }
481        },
482        "gemini" => match providers.gemini.as_mut() {
483            Some(c) => {
484                c.api_key = api_key.to_string();
485                true
486            }
487            None => {
488                providers.gemini = serde_json::from_value(stanza()).ok();
489                providers.gemini.is_some()
490            }
491        },
492        _ => false,
493    };
494    if !applied {
495        tracing::warn!(
496            provider = %config.provider,
497            "AgentBuilder::api_key: key not applied — the active provider either \
498             takes no plain api_key (e.g. copilot uses cached OAuth) or its config \
499             could not be built from a key alone"
500        );
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::apply_api_key;
507    use bamboo_llm::Config;
508
509    /// `.api_key()` must FABRICATE a usable stanza for each keyed provider when
510    /// the config has none — i.e. a key-only JSON deserializes into the provider
511    /// config. If a provider struct ever gains a required, non-`#[serde(default)]`
512    /// field, `serde_json::from_value` fails and this test catches it (instead of
513    /// the feature silently degrading to a confusing runtime warning).
514    #[test]
515    fn api_key_fabricates_stanza_for_keyed_providers() {
516        for provider in ["openai", "anthropic", "gemini"] {
517            let mut config = Config::default();
518            config.provider = provider.to_string();
519            // Force the absent-stanza (fabricate) path.
520            config.providers_mut().openai = None;
521            config.providers_mut().anthropic = None;
522            config.providers_mut().gemini = None;
523
524            apply_api_key(&mut config, "sk-test-123");
525
526            let key = match provider {
527                "openai" => config
528                    .providers()
529                    .openai
530                    .as_ref()
531                    .map(|c| c.api_key.as_str()),
532                "anthropic" => config
533                    .providers()
534                    .anthropic
535                    .as_ref()
536                    .map(|c| c.api_key.as_str()),
537                "gemini" => config
538                    .providers()
539                    .gemini
540                    .as_ref()
541                    .map(|c| c.api_key.as_str()),
542                _ => unreachable!(),
543            };
544            assert_eq!(
545                key,
546                Some("sk-test-123"),
547                "expected a fabricated {provider} stanza carrying the api_key"
548            );
549        }
550    }
551
552    /// The contract `.provider_name()` relies on: selecting the provider (setting
553    /// `config.provider`) BEFORE `apply_api_key` routes the key onto the chosen
554    /// provider, not config.json's default. Regression guard for the ordering in
555    /// `with_defaults_for_data_dir`.
556    #[test]
557    fn provider_selection_before_api_key_routes_key_to_chosen_provider() {
558        let mut config = Config::default();
559        config.provider = "anthropic".to_string(); // config.json default
560        config.providers_mut().openai = None;
561        config.providers_mut().anthropic = None;
562
563        // Simulate `.provider_name("openai")` (set first), then `.api_key(...)`.
564        config.provider = "openai".to_string();
565        apply_api_key(&mut config, "sk-openai-xyz");
566
567        assert_eq!(
568            config
569                .providers()
570                .openai
571                .as_ref()
572                .map(|c| c.api_key.as_str()),
573            Some("sk-openai-xyz"),
574            "api_key must land on the selected provider (openai), not the default"
575        );
576        assert!(
577            config.providers().anthropic.is_none(),
578            "the default provider must not receive the key"
579        );
580    }
581}