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, HookRunner};
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 /// Install the immutable lifecycle-hook registry used by every run of this
259 /// agent. The registry is snapshotted into the sealed loop configuration at
260 /// run start, so one execution cannot observe mid-run registration changes.
261 pub fn hook_runner(mut self, runner: Arc<HookRunner>) -> Self {
262 self.inner = self.inner.hook_runner(runner);
263 self
264 }
265
266 // -- Explicit dependency injection (passthrough) ------------------------
267
268 /// Inject a pre-built LLM provider, bypassing config-driven creation.
269 pub fn provider(mut self, provider: Arc<dyn LLMProvider>) -> Self {
270 self.inner = self.inner.provider(provider);
271 self
272 }
273
274 /// Inject a pre-built default tool executor.
275 pub fn default_tools(mut self, tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor>) -> Self {
276 self.inner = self.inner.default_tools(tools);
277 self
278 }
279
280 /// Inject a shared config handle.
281 pub fn config(mut self, config: Arc<RwLock<Config>>) -> Self {
282 self.inner = self.inner.config(config);
283 self
284 }
285
286 // -- Default dependency assembly ---------------------------------------
287
288 /// Assemble the eight runtime dependencies rooted at `data_dir`, using only
289 /// the infrastructure / engine / tools layers (never `bamboo-server`):
290 ///
291 /// 1. `Config::from_data_dir(data_dir)` (with `api_key` applied if set)
292 /// 2. `SessionStoreV2` → `storage` + `attachment_reader`
293 /// 3. `LockedSessionStore` → `persistence`
294 /// 4. `SkillManager` (+ `initialize`)
295 /// 5. `MetricsCollector::spawn(SqliteMetricsStorage)`
296 /// 6. provider via `create_provider_with_dir`
297 /// 7. `BuiltinToolExecutor::new_with_config` → `default_tools`
298 ///
299 /// The engine builder is last-write-wins, so this method does NOT preserve
300 /// dependencies set before it. Call `with_defaults_for_data_dir` FIRST, then
301 /// override individual dependencies (e.g. [`provider`](Self::provider)) AFTER
302 /// it to make those overrides take precedence.
303 ///
304 /// # Precondition
305 ///
306 /// `<data_dir>/config.json` must define the active provider with a non-empty
307 /// `api_key` (the same config `bamboo serve` reads). A fresh data dir with no
308 /// `config.json` defaults to the `anthropic` provider with no key, so step 6
309 /// (`create_provider_with_dir`) returns [`SdkError::ProviderInit`].
310 /// The `copilot` provider is the only one that can authenticate keyless (via
311 /// its cached OAuth token). Set the key via the config file, or pass it on the
312 /// builder with [`api_key`](Self::api_key) **before** calling this method.
313 ///
314 /// If [`mcp_server`](Self::mcp_server)/[`mcp_servers`](Self::mcp_servers) were
315 /// configured, each server is connected here (in call order) and its tools
316 /// merged into the built-in tool surface; a connection failure fails the
317 /// whole call with [`SdkError::McpServerStart`]. If
318 /// [`permission_checker`](Self::permission_checker) was configured, it gates
319 /// the built-in tool executor from this point on.
320 pub async fn with_defaults_for_data_dir(mut self, data_dir: PathBuf) -> Result<Self, SdkError> {
321 // 1. Config.
322 let mut config = Config::from_data_dir(Some(data_dir.clone()));
323 // Select the provider first, so `api_key` and provider creation both act
324 // on the chosen provider rather than config.json's default.
325 if let Some(provider) = self.provider_name.clone() {
326 config.provider = provider;
327 }
328 if let Some(api_key) = self.api_key.clone() {
329 apply_api_key(&mut config, &api_key);
330 }
331
332 // 6. Provider (created before config is moved into the shared lock).
333 let provider = create_provider_with_dir(&config, data_dir.clone())
334 .await
335 .map_err(|e| SdkError::ProviderInit(e.to_string()))?;
336
337 // 7. Default tools (builtin + config-aware), optionally gated by a
338 // permission checker and merged with any configured MCP servers.
339 let config = Arc::new(RwLock::new(config));
340 let builtin_tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor> =
341 match self.permission_checker.clone() {
342 Some(checker) => Arc::new(
343 bamboo_tools::BuiltinToolExecutor::new_with_config_and_permissions(
344 config.clone(),
345 checker,
346 ),
347 ),
348 None => Arc::new(bamboo_tools::BuiltinToolExecutor::new_with_config(
349 config.clone(),
350 )),
351 };
352 let default_tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor> =
353 if self.mcp_servers.is_empty() {
354 builtin_tools
355 } else {
356 let mcp_manager = Arc::new(McpServerManager::new_with_config(config.clone()));
357 for server_config in &self.mcp_servers {
358 let server_id = server_config.id.clone();
359 mcp_manager
360 .start_server(server_config.clone())
361 .await
362 .map_err(|source| SdkError::McpServerStart { server_id, source })?;
363 }
364 let mcp_tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor> = Arc::new(
365 McpToolExecutor::new(mcp_manager.clone(), mcp_manager.tool_index()),
366 );
367 Arc::new(CompositeToolExecutor::new(builtin_tools, mcp_tools))
368 };
369
370 // 2/3. Storage + persistence + attachment reader.
371 let store = Arc::new(
372 SessionStoreV2::new(data_dir.clone())
373 .await
374 .map_err(|e| SdkError::StoreInit(e.to_string()))?,
375 );
376 let persistence = Arc::new(LockedSessionStore::new(store.clone()));
377
378 // 4. Skill manager.
379 let skill_manager = Arc::new(SkillManager::with_config(SkillStoreConfig {
380 skills_dir: data_dir.join("skills"),
381 project_dir: std::env::current_dir().ok(),
382 active_mode: None,
383 }));
384 skill_manager
385 .initialize()
386 .await
387 .map_err(|e| SdkError::SkillInit(e.to_string()))?;
388
389 // 5. Metrics collector.
390 let metrics_storage: Arc<dyn bamboo_metrics::storage::MetricsStorage> =
391 Arc::new(SqliteMetricsStorage::new(data_dir.join("metrics.db")));
392 let metrics_collector =
393 MetricsCollector::spawn(metrics_storage, DEFAULT_METRICS_RETENTION_DAYS);
394
395 self.session_store = Some(store.clone());
396 self.inner = self
397 .inner
398 .storage(store.clone())
399 .persistence(persistence)
400 .attachment_reader(store)
401 .skill_manager(skill_manager)
402 .metrics_collector(metrics_collector)
403 .config(config)
404 .provider(provider)
405 .default_tools(default_tools);
406
407 Ok(self)
408 }
409
410 /// Finalize into an [`Agent`].
411 ///
412 /// If a tool set was configured via [`tools`](Self::tools) / [`tool`](Self::tool),
413 /// the agent's default tool executor is built from exactly those tools, so
414 /// the advertised tool surface is precisely the caller's selection (this
415 /// REPLACES any MCP composition from
416 /// [`mcp_server`](Self::mcp_server)/[`mcp_servers`](Self::mcp_servers) —
417 /// existing behavior, unchanged). With no selection, the full default
418 /// built-in (+ MCP, if configured) surface is used. The configured
419 /// `instruction` and `model` are carried onto the `Agent` for
420 /// [`Agent::run`](super::Agent::run).
421 pub fn build(mut self) -> Result<Agent, SdkError> {
422 if !self.tools.is_empty() {
423 let registry = ToolRegistry::new();
424 for tool in &self.tools {
425 let _ = registry.register_shared(tool.clone());
426 }
427 let executor: Arc<dyn ToolExecutor> =
428 Arc::new(bamboo_tools::BuiltinToolExecutor::with_registry(registry));
429 self.inner = self.inner.default_tools(executor);
430 }
431
432 let runtime = self
433 .inner
434 .build()
435 .map_err(|e| SdkError::Build(e.to_string()))?;
436 Ok(Agent::from_runtime_with_config(
437 runtime,
438 self.system_prompt,
439 self.model,
440 self.session_store,
441 self.permission_checker,
442 ))
443 }
444}
445
446impl Default for AgentBuilder {
447 fn default() -> Self {
448 Self::new()
449 }
450}
451
452/// Apply `api_key` to the active provider's in-memory config slot.
453///
454/// If the provider stanza already exists, its key is overwritten. If it is
455/// absent (the common default-config / no-`config.json` case), a minimal stanza
456/// is **fabricated** from `{"api_key": …}` — every other field is serde-default
457/// (all `Option`/`Vec`/`flatten`), so `.api_key("sk-…")` alone is enough to make
458/// a fresh data dir usable. Only the keyed providers (`openai` / `anthropic` /
459/// `gemini`) are fabricated; other providers (e.g. `copilot`, which authenticates
460/// via cached OAuth rather than a plain key) fall through to a warning.
461fn apply_api_key(config: &mut Config, api_key: &str) {
462 // A minimal `{"api_key": …}` stanza; every other provider-config field
463 // deserializes to its serde default, so the target type is inferred from
464 // the assignment below (no `serde` trait import needed).
465 let stanza = || serde_json::json!({ "api_key": api_key });
466
467 let provider = config.provider.clone();
468 let providers = config.providers_mut();
469 let applied = match provider.as_str() {
470 "openai" => match providers.openai.as_mut() {
471 Some(c) => {
472 c.api_key = api_key.to_string();
473 true
474 }
475 None => {
476 providers.openai = serde_json::from_value(stanza()).ok();
477 providers.openai.is_some()
478 }
479 },
480 "anthropic" => match providers.anthropic.as_mut() {
481 Some(c) => {
482 c.api_key = api_key.to_string();
483 true
484 }
485 None => {
486 providers.anthropic = serde_json::from_value(stanza()).ok();
487 providers.anthropic.is_some()
488 }
489 },
490 "gemini" => match providers.gemini.as_mut() {
491 Some(c) => {
492 c.api_key = api_key.to_string();
493 true
494 }
495 None => {
496 providers.gemini = serde_json::from_value(stanza()).ok();
497 providers.gemini.is_some()
498 }
499 },
500 _ => false,
501 };
502 if !applied {
503 tracing::warn!(
504 provider = %config.provider,
505 "AgentBuilder::api_key: key not applied — the active provider either \
506 takes no plain api_key (e.g. copilot uses cached OAuth) or its config \
507 could not be built from a key alone"
508 );
509 }
510}
511
512#[cfg(test)]
513mod tests {
514 use super::apply_api_key;
515 use bamboo_llm::Config;
516
517 /// `.api_key()` must FABRICATE a usable stanza for each keyed provider when
518 /// the config has none — i.e. a key-only JSON deserializes into the provider
519 /// config. If a provider struct ever gains a required, non-`#[serde(default)]`
520 /// field, `serde_json::from_value` fails and this test catches it (instead of
521 /// the feature silently degrading to a confusing runtime warning).
522 #[test]
523 fn api_key_fabricates_stanza_for_keyed_providers() {
524 for provider in ["openai", "anthropic", "gemini"] {
525 let mut config = Config::default();
526 config.provider = provider.to_string();
527 // Force the absent-stanza (fabricate) path.
528 config.providers_mut().openai = None;
529 config.providers_mut().anthropic = None;
530 config.providers_mut().gemini = None;
531
532 apply_api_key(&mut config, "sk-test-123");
533
534 let key = match provider {
535 "openai" => config
536 .providers()
537 .openai
538 .as_ref()
539 .map(|c| c.api_key.as_str()),
540 "anthropic" => config
541 .providers()
542 .anthropic
543 .as_ref()
544 .map(|c| c.api_key.as_str()),
545 "gemini" => config
546 .providers()
547 .gemini
548 .as_ref()
549 .map(|c| c.api_key.as_str()),
550 _ => unreachable!(),
551 };
552 assert_eq!(
553 key,
554 Some("sk-test-123"),
555 "expected a fabricated {provider} stanza carrying the api_key"
556 );
557 }
558 }
559
560 /// The contract `.provider_name()` relies on: selecting the provider (setting
561 /// `config.provider`) BEFORE `apply_api_key` routes the key onto the chosen
562 /// provider, not config.json's default. Regression guard for the ordering in
563 /// `with_defaults_for_data_dir`.
564 #[test]
565 fn provider_selection_before_api_key_routes_key_to_chosen_provider() {
566 let mut config = Config::default();
567 config.provider = "anthropic".to_string(); // config.json default
568 config.providers_mut().openai = None;
569 config.providers_mut().anthropic = None;
570
571 // Simulate `.provider_name("openai")` (set first), then `.api_key(...)`.
572 config.provider = "openai".to_string();
573 apply_api_key(&mut config, "sk-openai-xyz");
574
575 assert_eq!(
576 config
577 .providers()
578 .openai
579 .as_ref()
580 .map(|c| c.api_key.as_str()),
581 Some("sk-openai-xyz"),
582 "api_key must land on the selected provider (openai), not the default"
583 );
584 assert!(
585 config.providers().anthropic.is_none(),
586 "the default provider must not receive the key"
587 );
588 }
589}