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 applied = match config.provider.as_str() {
460 "openai" => match config.providers.openai.as_mut() {
461 Some(c) => {
462 c.api_key = api_key.to_string();
463 true
464 }
465 None => {
466 config.providers.openai = serde_json::from_value(stanza()).ok();
467 config.providers.openai.is_some()
468 }
469 },
470 "anthropic" => match config.providers.anthropic.as_mut() {
471 Some(c) => {
472 c.api_key = api_key.to_string();
473 true
474 }
475 None => {
476 config.providers.anthropic = serde_json::from_value(stanza()).ok();
477 config.providers.anthropic.is_some()
478 }
479 },
480 "gemini" => match config.providers.gemini.as_mut() {
481 Some(c) => {
482 c.api_key = api_key.to_string();
483 true
484 }
485 None => {
486 config.providers.gemini = serde_json::from_value(stanza()).ok();
487 config.providers.gemini.is_some()
488 }
489 },
490 _ => false,
491 };
492 if !applied {
493 tracing::warn!(
494 provider = %config.provider,
495 "AgentBuilder::api_key: key not applied — the active provider either \
496 takes no plain api_key (e.g. copilot uses cached OAuth) or its config \
497 could not be built from a key alone"
498 );
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use super::apply_api_key;
505 use bamboo_llm::Config;
506
507 /// `.api_key()` must FABRICATE a usable stanza for each keyed provider when
508 /// the config has none — i.e. a key-only JSON deserializes into the provider
509 /// config. If a provider struct ever gains a required, non-`#[serde(default)]`
510 /// field, `serde_json::from_value` fails and this test catches it (instead of
511 /// the feature silently degrading to a confusing runtime warning).
512 #[test]
513 fn api_key_fabricates_stanza_for_keyed_providers() {
514 for provider in ["openai", "anthropic", "gemini"] {
515 let mut config = Config::default();
516 config.provider = provider.to_string();
517 // Force the absent-stanza (fabricate) path.
518 config.providers.openai = None;
519 config.providers.anthropic = None;
520 config.providers.gemini = None;
521
522 apply_api_key(&mut config, "sk-test-123");
523
524 let key = match provider {
525 "openai" => config.providers.openai.as_ref().map(|c| c.api_key.as_str()),
526 "anthropic" => config
527 .providers
528 .anthropic
529 .as_ref()
530 .map(|c| c.api_key.as_str()),
531 "gemini" => config.providers.gemini.as_ref().map(|c| c.api_key.as_str()),
532 _ => unreachable!(),
533 };
534 assert_eq!(
535 key,
536 Some("sk-test-123"),
537 "expected a fabricated {provider} stanza carrying the api_key"
538 );
539 }
540 }
541
542 /// The contract `.provider_name()` relies on: selecting the provider (setting
543 /// `config.provider`) BEFORE `apply_api_key` routes the key onto the chosen
544 /// provider, not config.json's default. Regression guard for the ordering in
545 /// `with_defaults_for_data_dir`.
546 #[test]
547 fn provider_selection_before_api_key_routes_key_to_chosen_provider() {
548 let mut config = Config::default();
549 config.provider = "anthropic".to_string(); // config.json default
550 config.providers.openai = None;
551 config.providers.anthropic = None;
552
553 // Simulate `.provider_name("openai")` (set first), then `.api_key(...)`.
554 config.provider = "openai".to_string();
555 apply_api_key(&mut config, "sk-openai-xyz");
556
557 assert_eq!(
558 config.providers.openai.as_ref().map(|c| c.api_key.as_str()),
559 Some("sk-openai-xyz"),
560 "api_key must land on the selected provider (openai), not the default"
561 );
562 assert!(
563 config.providers.anthropic.is_none(),
564 "the default provider must not receive the key"
565 );
566 }
567}