Skip to main content

bamboo_engine/runtime/
agent.rs

1//! Stable public API for the agent runtime.
2//!
3//! [`Agent`] wraps an [`AgentRuntime`] with method-based access and serves as
4//! the primary entry point for SDK consumers.
5
6use std::sync::Arc;
7
8use bamboo_agent_core::Session;
9
10use crate::runtime::{AgentRuntime, AgentRuntimeBuilder, ExecuteRequest};
11use bamboo_domain::RuntimeSessionPersistence;
12
13// An inherited policy may decline the supplied delegate, but an opaque new
14// wrapper/provider cannot prove it still represents the managed selected route.
15// Refuse only this Gold selection instead of dropping the inherited policy.
16struct ChildGoldPolicyRefusal {
17    dispatched: std::sync::atomic::AtomicBool,
18    rejected: Option<Arc<dyn bamboo_llm::LLMProvider>>,
19}
20
21impl ChildGoldPolicyRefusal {
22    fn unverified_error() -> bamboo_llm::LLMError {
23        bamboo_llm::LLMError::Api(
24            "Existing Gold policy returned an unverified provider for managed child resume".into(),
25        )
26    }
27
28    fn error(&self) -> bamboo_llm::LLMError {
29        if self
30            .rejected
31            .as_ref()
32            .is_some_and(|provider| provider.local_dispatch_refused())
33        {
34            return stale_gold_policy_error();
35        }
36        self.rejected
37            .as_ref()
38            .and_then(|provider| provider.known_pre_dispatch_error())
39            .unwrap_or_else(Self::unverified_error)
40    }
41}
42
43fn stale_gold_policy_error() -> bamboo_llm::LLMError {
44    bamboo_llm::LLMError::Api(
45        "Inherited Gold target already refused a dispatch; a fresh target is required".into(),
46    )
47}
48
49#[async_trait::async_trait]
50impl bamboo_llm::LLMProvider for ChildGoldPolicyRefusal {
51    fn known_pre_dispatch_error(&self) -> Option<bamboo_llm::LLMError> {
52        Some(self.error())
53    }
54    fn queue_requires_current_route(&self) -> bool {
55        // Keep the default unique queue identity. A prior allowed queued Gold
56        // request must not compare equal when the current policy now refuses.
57        true
58    }
59    fn local_dispatch_refused(&self) -> bool {
60        self.dispatched.load(std::sync::atomic::Ordering::Acquire)
61    }
62    async fn chat_stream(
63        &self,
64        _: &[bamboo_agent_core::Message],
65        _: &[bamboo_agent_core::ToolSchema],
66        _: Option<u32>,
67        _: &str,
68    ) -> Result<bamboo_llm::LLMStream, bamboo_llm::LLMError> {
69        self.dispatched
70            .store(true, std::sync::atomic::Ordering::Release);
71        Err(self.error())
72    }
73}
74
75pub(crate) fn compose_child_gold_policy(
76    inherited: Option<crate::runtime::config::GoldTargetResolver>,
77    managed: crate::runtime::config::GoldTargetResolver,
78) -> crate::runtime::config::GoldTargetResolver {
79    let Some(inherited) = inherited else {
80        return managed;
81    };
82    Arc::new(move |model, delegate| {
83        // `delegate` was already fixed to the selected provider after the
84        // optional Work decoration by bind_child_gold_delegate.
85        let result = inherited(model, delegate.clone());
86        if !gold_policy_reaches_delegate(&result, &delegate) {
87            return Arc::new(ChildGoldPolicyRefusal {
88                dispatched: std::sync::atomic::AtomicBool::new(false),
89                rejected: None,
90            }) as Arc<dyn bamboo_llm::LLMProvider>;
91        }
92        if result.local_dispatch_refused() {
93            return Arc::new(ChildGoldPolicyRefusal {
94                dispatched: std::sync::atomic::AtomicBool::new(false),
95                rejected: Some(result),
96            }) as Arc<dyn bamboo_llm::LLMProvider>;
97        }
98        let allocation_identity = gold_policy_allocation_identity(&result, &delegate);
99        let policy: Arc<dyn bamboo_llm::LLMProvider> = Arc::new(ChildGoldPolicyProvider {
100            inherited: inherited.clone(),
101            delegate,
102            allocation_identity,
103            model: model.to_owned(),
104            dispatched: std::sync::atomic::AtomicBool::new(false),
105        });
106        managed(model, policy)
107    })
108}
109
110fn gold_policy_allocation_identity(
111    policy: &Arc<dyn bamboo_llm::LLMProvider>,
112    delegate: &Arc<dyn bamboo_llm::LLMProvider>,
113) -> usize {
114    policy
115        .resource_allocation_identity()
116        .or_else(|| delegate.resource_allocation_identity())
117        .unwrap_or(Arc::as_ptr(delegate) as *const () as usize)
118}
119
120// Queue/allocation identity is intentionally not proof of dispatch provenance.
121// Only explicitly attested immutable delegate edges may reach the exact Work
122// decorated Arc supplied to the policy. Bound traversal so a bad adapter cannot
123// create an infinite cycle or unbounded inspection.
124fn gold_policy_reaches_delegate(
125    candidate: &Arc<dyn bamboo_llm::LLMProvider>,
126    delegate: &Arc<dyn bamboo_llm::LLMProvider>,
127) -> bool {
128    const MAX_WRAPPERS: usize = 32;
129    let mut seen = Vec::<Arc<dyn bamboo_llm::LLMProvider>>::new();
130    let mut current = candidate.clone();
131    for _ in 0..=MAX_WRAPPERS {
132        if Arc::ptr_eq(&current, delegate) {
133            return true;
134        }
135        if seen.iter().any(|previous| Arc::ptr_eq(previous, &current)) {
136            return false;
137        }
138        seen.push(current.clone());
139        let Some(next) = current.fixed_gold_policy_delegate() else {
140            return false;
141        };
142        current = next;
143    }
144    false
145}
146
147// The inherited resolver is a live policy. Re-evaluate it at dispatch, after
148// any auxiliary semaphore wait, and keep the returned wrapper rather than
149// discarding its policy. A non-attested result never enters the Work delegate.
150struct ChildGoldPolicyProvider {
151    inherited: crate::runtime::config::GoldTargetResolver,
152    delegate: Arc<dyn bamboo_llm::LLMProvider>,
153    allocation_identity: usize,
154    model: String,
155    dispatched: std::sync::atomic::AtomicBool,
156}
157
158impl ChildGoldPolicyProvider {
159    fn select(
160        &self,
161        model: &str,
162    ) -> Result<Arc<dyn bamboo_llm::LLMProvider>, bamboo_llm::LLMError> {
163        if model != self.model {
164            return Err(bamboo_llm::LLMError::Api(
165                "Gold model changed after policy selection".into(),
166            ));
167        }
168        let current = (self.inherited)(model, self.delegate.clone());
169        if !gold_policy_reaches_delegate(&current, &self.delegate) {
170            return Err(ChildGoldPolicyRefusal::unverified_error());
171        }
172        // The refusal marker is per target. Reusing a target that already
173        // refused could misclassify a later upstream error as a local refusal.
174        // Refuse before the Work delegate rather than invoking that target.
175        if current.local_dispatch_refused() {
176            return Err(stale_gold_policy_error());
177        }
178        // Keep the concurrency budget chosen for this evaluation. An adapter
179        // which changes its explicit allocation identity cannot safely reuse
180        // the slot already acquired for the prior policy.
181        if gold_policy_allocation_identity(&current, &self.delegate) != self.allocation_identity {
182            return Err(bamboo_llm::LLMError::Api(
183                "Gold policy changed its allocation identity before dispatch".into(),
184            ));
185        }
186        Ok(current)
187    }
188
189    fn dispatch(
190        &self,
191        model: &str,
192    ) -> Result<Arc<dyn bamboo_llm::LLMProvider>, bamboo_llm::LLMError> {
193        let selected = self.select(model).and_then(|provider| {
194            if let Some(error) = provider.known_pre_dispatch_error() {
195                Err(error)
196            } else {
197                Ok(provider)
198            }
199        });
200        if selected.is_err() {
201            self.dispatched
202                .store(true, std::sync::atomic::Ordering::Release);
203        }
204        selected
205    }
206
207    fn note_delegate_refusal<T>(
208        &self,
209        provider: &Arc<dyn bamboo_llm::LLMProvider>,
210        result: &Result<T, bamboo_llm::LLMError>,
211    ) {
212        if result.is_err() && provider.local_dispatch_refused() {
213            self.dispatched
214                .store(true, std::sync::atomic::Ordering::Release);
215        }
216    }
217}
218
219#[async_trait::async_trait]
220impl bamboo_llm::LLMProvider for ChildGoldPolicyProvider {
221    fn fixed_gold_policy_delegate(&self) -> Option<Arc<dyn bamboo_llm::LLMProvider>> {
222        // Every successful dispatch is rechecked to reach this immutable
223        // delegate; a changed or opaque route is refused before dispatch.
224        Some(self.delegate.clone())
225    }
226
227    fn known_pre_dispatch_error(&self) -> Option<bamboo_llm::LLMError> {
228        match self.select(&self.model) {
229            Ok(provider) => provider.known_pre_dispatch_error(),
230            Err(error) => Some(error),
231        }
232    }
233
234    fn queue_requires_current_route(&self) -> bool {
235        true
236    }
237
238    fn queued_route_identity(&self) -> bamboo_llm::provider::QueuedRouteIdentity {
239        bamboo_llm::provider::queued_identity_for(&self.delegate)
240    }
241
242    fn local_dispatch_refused(&self) -> bool {
243        self.dispatched.load(std::sync::atomic::Ordering::Acquire)
244    }
245
246    fn resource_allocation_identity(&self) -> Option<usize> {
247        Some(self.allocation_identity)
248    }
249
250    async fn supports_vision(&self, model: &str) -> bool {
251        match self.select(&self.model) {
252            Ok(provider) => provider.supports_vision(model).await,
253            Err(_) => false,
254        }
255    }
256
257    async fn vision_support_override(&self, model: &str) -> Option<bool> {
258        match self.select(&self.model) {
259            Ok(provider) => provider.vision_support_override(model).await,
260            Err(_) => Some(false),
261        }
262    }
263
264    async fn capability_loading_mode(
265        &self,
266        model: &str,
267        required: Option<&str>,
268    ) -> bamboo_domain::CapabilityLoadingMode {
269        match self.select(&self.model) {
270            Ok(provider) => provider.capability_loading_mode(model, required).await,
271            Err(_) => bamboo_domain::CapabilityLoadingMode::LegacyFullCatalog,
272        }
273    }
274
275    async fn provider_visible_tool_footprint(
276        &self,
277        ir: &bamboo_llm::PromptIR,
278        tools: &[bamboo_agent_core::ToolSchema],
279        model: &str,
280        required: Option<&str>,
281    ) -> Result<bamboo_llm::ProviderVisibleToolFootprint, bamboo_llm::LLMError> {
282        self.select(model)?
283            .provider_visible_tool_footprint(ir, tools, model, required)
284            .await
285    }
286
287    async fn chat_stream(
288        &self,
289        messages: &[bamboo_agent_core::Message],
290        tools: &[bamboo_agent_core::ToolSchema],
291        max: Option<u32>,
292        model: &str,
293    ) -> Result<bamboo_llm::LLMStream, bamboo_llm::LLMError> {
294        let provider = self.dispatch(model)?;
295        let result = provider.chat_stream(messages, tools, max, model).await;
296        self.note_delegate_refusal(&provider, &result);
297        result
298    }
299
300    async fn chat_stream_with_options(
301        &self,
302        messages: &[bamboo_agent_core::Message],
303        tools: &[bamboo_agent_core::ToolSchema],
304        max: Option<u32>,
305        model: &str,
306        options: Option<&bamboo_llm::LLMRequestOptions>,
307    ) -> Result<bamboo_llm::LLMStream, bamboo_llm::LLMError> {
308        let provider = self.dispatch(model)?;
309        let result = provider
310            .chat_stream_with_options(messages, tools, max, model, options)
311            .await;
312        self.note_delegate_refusal(&provider, &result);
313        result
314    }
315
316    async fn chat_stream_ir(
317        &self,
318        ir: &bamboo_llm::PromptIR,
319        tools: &[bamboo_agent_core::ToolSchema],
320        max: Option<u32>,
321        model: &str,
322        options: Option<&bamboo_llm::LLMRequestOptions>,
323    ) -> Result<bamboo_llm::LLMStream, bamboo_llm::LLMError> {
324        let provider = self.dispatch(model)?;
325        let result = provider
326            .chat_stream_ir(ir, tools, max, model, options)
327            .await;
328        self.note_delegate_refusal(&provider, &result);
329        result
330    }
331
332    async fn list_models(&self) -> Result<Vec<String>, bamboo_llm::LLMError> {
333        self.select(&self.model)?.list_models().await
334    }
335
336    async fn list_model_info(
337        &self,
338    ) -> Result<Vec<bamboo_llm::provider::ProviderModelInfo>, bamboo_llm::LLMError> {
339        self.select(&self.model)?.list_model_info().await
340    }
341}
342
343// ---------------------------------------------------------------------------
344// Agent — stable public object
345// ---------------------------------------------------------------------------
346
347/// Stable public entry point for agent execution.
348///
349/// Wraps an [`AgentRuntime`] and provides:
350/// - [`Agent::execute()`] — run the agent loop on a session
351/// - [`Agent::storage()`] — access the shared storage backend
352///
353/// Clone is cheap (inner is `Arc`).
354#[derive(Clone)]
355pub struct Agent {
356    runtime: Arc<AgentRuntime>,
357}
358
359/// Opaque ownership lease for one direct logical-session execution.
360///
361/// SDK facades acquire this before any pre-execution side effect, then transfer
362/// it into [`Agent::execute_direct_registered`]. Dropping it early invokes the
363/// same abandoned-owner recovery as cancellation during provider execution.
364pub struct DirectExecutionLease {
365    target_session_id: String,
366    router: Option<Arc<crate::session_activation::SessionActivationRouter>>,
367    registration: Option<crate::session_activation::SessionRunRegistration>,
368}
369
370impl DirectExecutionLease {
371    /// Release ownership before returning a handled pre-execution stop to an
372    /// SDK caller, so its next resume does not race the asynchronous Drop path.
373    pub async fn abandon(mut self) {
374        if let Some(registration) = self.registration.take() {
375            registration.abandon().await;
376        }
377    }
378}
379
380/// Opaque, single-use SDK submission receipt. The owned User is the genuine
381/// portable-hook result; subsequent F preparation may change only its prompt.
382pub struct SdkInputSubmission {
383    session_id: String,
384    execution_id: String,
385    user: bamboo_agent_core::Message,
386    hook_runner: Arc<crate::runtime::hooks::HookRunner>,
387}
388
389impl SdkInputSubmission {
390    pub fn execution_id(&self) -> &str {
391        &self.execution_id
392    }
393    pub fn user(&self) -> &bamboo_agent_core::Message {
394        &self.user
395    }
396}
397
398impl Agent {
399    /// Wrap an existing [`AgentRuntime`] in an `Agent`.
400    pub fn from_runtime(runtime: Arc<AgentRuntime>) -> Self {
401        Agent { runtime }
402    }
403
404    /// Return only the Host's still-current explicitly injected provider.
405    #[doc(hidden)]
406    pub fn legacy_injected_provider_snapshot(
407        &self,
408    ) -> Result<Option<Arc<dyn bamboo_llm::LLMProvider>>, bamboo_llm::LLMError> {
409        self.runtime.provider.legacy_injected_provider_snapshot()
410    }
411
412    /// Freeze the same verified injection and retain its Host budget identity.
413    #[doc(hidden)]
414    pub fn fixed_legacy_injected_provider(
415        &self,
416        expected: &Arc<dyn bamboo_llm::LLMProvider>,
417    ) -> Result<Option<Arc<dyn bamboo_llm::LLMProvider>>, bamboo_llm::LLMError> {
418        Ok(self
419            .runtime
420            .provider
421            .legacy_injected_provider_snapshot()?
422            .and_then(|current| {
423                Arc::ptr_eq(&current, expected).then(|| {
424                    bamboo_llm::provider::fixed_injected_provider_with_identity(
425                        current,
426                        self.runtime.provider.clone(),
427                    )
428                })
429            }))
430    }
431
432    /// One execution's immutable persistence capability. Shared tool/provider
433    /// resources remain on the existing runtime; default callers stay unbound.
434    #[doc(hidden)]
435    pub fn with_execution_persistence(
436        &self,
437        persistence: Arc<dyn RuntimeSessionPersistence>,
438    ) -> Self {
439        let mut runtime = (*self.runtime).clone();
440        runtime.persistence = persistence;
441        runtime.inherited_child_wait_captured = true;
442        Self::from_runtime(Arc::new(runtime))
443    }
444
445    /// Mark this execution's legacy image fallback as derived by the Host
446    /// from configuration. The shared agent and caller-owned runs stay unchanged.
447    #[doc(hidden)]
448    pub fn with_config_derived_image_fallback(&self) -> Self {
449        let mut runtime = (*self.runtime).clone();
450        runtime.legacy_image_from_config = true;
451        Self::from_runtime(Arc::new(runtime))
452    }
453
454    /// Replace the per-run evidence for the selected legacy Vision client.
455    #[doc(hidden)]
456    pub fn with_legacy_vision_provider_source(
457        &self,
458        source: Option<Arc<bamboo_llm::provider_registry::LegacyVisionProviderSource>>,
459    ) -> Self {
460        let mut runtime = (*self.runtime).clone();
461        runtime.legacy_image_provider_source = source;
462        Self::from_runtime(Arc::new(runtime))
463    }
464
465    /// Adapt the default provider for this execution while retaining every
466    /// other runtime capability. Explicit request-level overrides stay separate.
467    #[doc(hidden)]
468    pub fn with_default_provider_adapter(
469        &self,
470        adapter: impl FnOnce(Arc<dyn bamboo_llm::LLMProvider>) -> Arc<dyn bamboo_llm::LLMProvider>,
471    ) -> Self {
472        let mut runtime = (*self.runtime).clone();
473        runtime.provider = adapter(runtime.provider.clone());
474        Self::from_runtime(Arc::new(runtime))
475    }
476
477    /// Bind a live-bootstrap Host to this execution only. The Host must retain
478    /// its exact runner and writer; this method does not create authority.
479    #[doc(hidden)]
480    pub fn with_live_bootstrap_host(
481        &self,
482        host: Arc<dyn crate::runtime::config::LiveBootstrapHost>,
483    ) -> Self {
484        let mut runtime = (*self.runtime).clone();
485        runtime.live_bootstrap_host = Some(host);
486        Self::from_runtime(Arc::new(runtime))
487    }
488
489    /// Bind a live background route to this execution without changing other roles.
490    #[doc(hidden)]
491    pub fn with_background_model_resolver(
492        &self,
493        resolver: crate::runtime::config::BackgroundModelResolver,
494    ) -> Self {
495        let mut runtime = (*self.runtime).clone();
496        runtime.background_model_resolver = Some(resolver);
497        Self::from_runtime(Arc::new(runtime))
498    }
499
500    /// Install a run-keyed auxiliary resolver on this Agent clone only.
501    #[doc(hidden)]
502    pub fn with_auxiliary_model_resolver_factory(
503        &self,
504        factory: Arc<
505            dyn Fn(&str) -> Arc<dyn Fn() -> crate::AuxiliaryModelConfig + Send + Sync>
506                + Send
507                + Sync,
508        >,
509    ) -> Self {
510        let mut runtime = (*self.runtime).clone();
511        runtime.auxiliary_model_resolver_factory = Some(factory);
512        // The factory resolves background and summary with the other roles.
513        // Inherited overrides could belong to another run and would otherwise
514        // replace those selections with a second config read.
515        runtime.background_model_resolver = None;
516        runtime.resume_auxiliary_resolver = None;
517        Self::from_runtime(Arc::new(runtime))
518    }
519
520    /// Bind a resumed Connect run's live summary/background selection.
521    #[doc(hidden)]
522    pub fn with_resume_auxiliary_resolver(
523        &self,
524        resolver: crate::runtime::config::ResumeAuxiliaryResolver,
525    ) -> Self {
526        let mut runtime = (*self.runtime).clone();
527        runtime.resume_auxiliary_resolver = Some(resolver);
528        Self::from_runtime(Arc::new(runtime))
529    }
530
531    /// Opt a Host-owned non-HTTP consumer into per-attempt Gold provenance.
532    /// Direct SDK Agents keep their existing provider contract.
533    #[doc(hidden)]
534    pub fn with_host_gold_origin_resolver(
535        &self,
536        resolver: crate::runtime::host_gold::HostGoldOriginResolver,
537    ) -> Self {
538        let mut runtime = (*self.runtime).clone();
539        runtime.host_gold_origin_resolver = Some(resolver);
540        Self::from_runtime(Arc::new(runtime))
541    }
542
543    /// Bind the Host's Gold route to this execution without changing SDK defaults.
544    /// For managed child resume the synchronous resolver may be called again
545    /// at preflight and dispatch; it must not initiate a request or block.
546    #[doc(hidden)]
547    pub fn with_gold_target_resolver(
548        &self,
549        resolver: crate::runtime::config::GoldTargetResolver,
550    ) -> Self {
551        let mut runtime = (*self.runtime).clone();
552        runtime.gold_target_resolver = Some(resolver);
553        runtime.child_resume_gold_selected = None;
554        Self::from_runtime(Arc::new(runtime))
555    }
556
557    /// Add a managed Host policy while retaining any inherited Gold policy.
558    #[doc(hidden)]
559    pub fn with_composed_gold_target_resolver(
560        &self,
561        resolver: crate::runtime::config::GoldTargetResolver,
562    ) -> Self {
563        let mut runtime = (*self.runtime).clone();
564        runtime.gold_target_resolver = Some(compose_child_gold_policy(
565            runtime.gold_target_resolver.take(),
566            resolver,
567        ));
568        Self::from_runtime(Arc::new(runtime))
569    }
570
571    // Only managed child resume opts into an exact selected-main check. SDK
572    // and other Host resolvers retain their existing delegate behavior.
573    pub(crate) fn with_child_resume_gold_target_resolver(
574        &self,
575        selected: Arc<dyn bamboo_llm::LLMProvider>,
576        resolver: crate::runtime::config::GoldTargetResolver,
577    ) -> Self {
578        let mut runtime = (*self.runtime).clone();
579        runtime.gold_target_resolver = Some(compose_child_gold_policy(
580            runtime.gold_target_resolver.take(),
581            resolver,
582        ));
583        runtime.child_resume_gold_selected = Some(selected);
584        Self::from_runtime(Arc::new(runtime))
585    }
586
587    /// Install the registered SDK adapter on this execution's runtime only.
588    #[doc(hidden)]
589    pub fn with_sdk_skill_execution_host(
590        &self,
591        host: Arc<dyn crate::runtime::config::SdkSkillExecutionHost>,
592    ) -> Self {
593        self.with_skill_execution_host(host)
594    }
595
596    /// Attach an execution-local registered host; this creates no caller authority.
597    #[doc(hidden)]
598    pub fn with_skill_execution_host(
599        &self,
600        host: Arc<dyn crate::runtime::config::SkillExecutionHost>,
601    ) -> Self {
602        let mut runtime = (*self.runtime).clone();
603        runtime.sdk_skill_execution_host = Some(host);
604        Self::from_runtime(Arc::new(runtime))
605    }
606
607    /// Bind Workspace adoption to this execution without opting into Skill mode.
608    #[doc(hidden)]
609    pub fn with_workspace_execution_host(
610        &self,
611        host: Arc<dyn crate::runtime::config::WorkspaceExecutionHost>,
612    ) -> Self {
613        let mut runtime = (*self.runtime).clone();
614        runtime.workspace_execution_host = Some(host);
615        Self::from_runtime(Arc::new(runtime))
616    }
617
618    /// Run the genuine portable submission hook before F and append. No old
619    /// metadata receipt may skip this new submission's hook.
620    #[doc(hidden)]
621    pub async fn precheck_sdk_user_input(
622        &self,
623        session: &mut Session,
624        user: &bamboo_agent_core::Message,
625        app_data_dir: Option<std::path::PathBuf>,
626    ) -> crate::runtime::runner::Result<SdkInputSubmission> {
627        session
628            .check_execution_compatibility()
629            .map_err(|error| bamboo_agent_core::AgentError::Tool(error.to_string()))?;
630        if user.role != bamboo_agent_core::Role::User
631            || crate::runtime::config::UntrustedInputObservation::new(&user.id, None).is_none()
632            || session.messages.iter().any(|message| message.id == user.id)
633        {
634            return Err(bamboo_agent_core::AgentError::Tool(
635                "invalid new SDK User".into(),
636            ));
637        }
638        let hook_runner = {
639            let config = self.runtime.config.read().await;
640            Arc::new(
641                self.runtime
642                    .hook_runner
643                    .with_lifecycle_config(&config.lifecycle_hooks, app_data_dir),
644            )
645        };
646        session.metadata.remove("runtime.plugin_prompt_prechecked");
647        let prompt = hook_runner
648            .apply_portable_user_prompt(session, &user.content)
649            .await?;
650        let mut transformed = user.clone();
651        transformed.content = prompt.clone();
652        if let Some(parts) = transformed.content_parts.as_mut() {
653            if let Some(bamboo_domain::MessagePart::Text { text }) =
654                parts.iter_mut().find(|part| matches!(part, bamboo_domain::MessagePart::Text { text } if text == &user.content))
655            {
656                *text = prompt;
657            }
658        }
659        Ok(SdkInputSubmission {
660            session_id: session.id.clone(),
661            execution_id: crate::runtime::runner::round_prelude::new_execution_id(),
662            user: transformed,
663            hook_runner,
664        })
665    }
666
667    /// Actual SDK producer append. The private pending receipt becomes checked
668    /// only after startup hooks and a durable same-repository checkpoint.
669    #[doc(hidden)]
670    pub fn append_sdk_user_input(
671        &self,
672        session: &mut Session,
673        user: bamboo_agent_core::Message,
674        receipt: Option<SdkInputSubmission>,
675        request: Option<bamboo_domain::SessionSkillRequest>,
676    ) -> crate::runtime::runner::Result<crate::runtime::config::UntrustedExecutionInputs> {
677        session
678            .check_execution_compatibility()
679            .map_err(|error| bamboo_agent_core::AgentError::Tool(error.to_string()))?;
680        use crate::runtime::config::{
681            sdk_message_digest, sdk_message_header_digest, SdkPendingInput,
682            UntrustedExecutionInputs, UntrustedInputObservation,
683        };
684        let invalid = || bamboo_agent_core::AgentError::Tool("invalid SDK append receipt".into());
685        let header_digest = sdk_message_header_digest(&user);
686        let input_id = user.id.clone();
687        if let Some(receipt) = receipt.as_ref() {
688            if receipt.session_id != session.id
689                || receipt.user.role != bamboo_agent_core::Role::User
690                || user.role != bamboo_agent_core::Role::User
691                || sdk_message_header_digest(&receipt.user) != header_digest
692                || session
693                    .messages
694                    .iter()
695                    .any(|message| message.id == input_id)
696            {
697                return Err(invalid());
698            }
699        }
700        // Preserve the legacy String append even when optional observation data
701        // is malformed. Typed callers fail before append on an invalid receipt.
702        let final_digest = receipt.as_ref().map(|_| sdk_message_digest(&user));
703        if receipt.is_some() {
704            crate::runtime::hooks::HookRunner::mark_user_prompt_prechecked(session, &user.content);
705        }
706        session.add_message(user);
707        let observation =
708            UntrustedInputObservation::new(&input_id, request.as_ref()).ok_or_else(invalid)?;
709        let carrier = UntrustedExecutionInputs::new(vec![observation]).ok_or_else(invalid)?;
710        let (execution_id, hook_runner) = receipt
711            .map(|receipt| (receipt.execution_id, Some(receipt.hook_runner)))
712            .unwrap_or_else(|| {
713                (
714                    crate::runtime::runner::round_prelude::new_execution_id(),
715                    None,
716                )
717            });
718        Ok(carrier.bind_sdk_append(
719            execution_id,
720            SdkPendingInput {
721                session_id: session.id.clone(),
722                input_id,
723                header_digest,
724                final_digest,
725            },
726            hook_runner,
727        ))
728    }
729
730    /// Return a new builder.
731    pub fn builder() -> AgentBuilder {
732        AgentBuilder::new()
733    }
734
735    /// Execute the agent loop with the given request.
736    pub async fn execute(
737        &self,
738        session: &mut Session,
739        req: ExecuteRequest,
740    ) -> crate::runtime::runner::Result<()> {
741        self.runtime.execute(session, req).await
742    }
743
744    /// Execute with an explicit initial upstream request policy.
745    pub async fn execute_with_upstream_retry(
746        &self,
747        session: &mut Session,
748        req: ExecuteRequest,
749        upstream_retry: bamboo_llm::retry::UpstreamRetry,
750    ) -> crate::runtime::runner::Result<()> {
751        self.runtime
752            .execute_with_upstream_retry(session, req, upstream_retry)
753            .await
754    }
755
756    /// Explicit bounded caller-data handoff; no currentness or Skill authority.
757    /// Ordinary execute/direct/spawn entrypoints continue to supply None.
758    pub async fn execute_with_inputs(
759        &self,
760        session: &mut Session,
761        req: ExecuteRequest,
762        inputs: Option<crate::runtime::config::UntrustedExecutionInputs>,
763    ) -> crate::runtime::runner::Result<()> {
764        self.runtime.execute_with_inputs(session, req, inputs).await
765    }
766
767    pub(crate) async fn execute_with_inputs_and_upstream_retry(
768        &self,
769        session: &mut Session,
770        req: ExecuteRequest,
771        inputs: Option<crate::runtime::config::UntrustedExecutionInputs>,
772        upstream_retry: bamboo_llm::retry::UpstreamRetry,
773    ) -> crate::runtime::runner::Result<()> {
774        self.runtime
775            .execute_with_inputs_and_upstream_retry(session, req, inputs, upstream_retry)
776            .await
777    }
778
779    /// Execute a caller-owned session under a complete logical-session
780    /// activation lifecycle.
781    ///
782    /// Server/child entry points already own an external runner reservation and
783    /// therefore use [`execute`](Self::execute) plus their existing terminal
784    /// handshake. Direct SDK callers have no runner registry, so this wrapper
785    /// registers the current run before entering the provider loop, marks it
786    /// finalizing immediately after return, migrates any terminal-window legacy
787    /// ingress, and lets the router reserve at most one successor for work the
788    /// completed reasoning turn did not admit.
789    pub async fn execute_direct(
790        &self,
791        session: &mut Session,
792        req: ExecuteRequest,
793    ) -> crate::runtime::runner::Result<()> {
794        self.execute_direct_with_upstream_retry(
795            session,
796            req,
797            bamboo_llm::retry::UpstreamRetry::Bounded,
798        )
799        .await
800    }
801
802    /// Execute through the direct owner lifecycle with an explicit retry policy.
803    pub async fn execute_direct_with_upstream_retry(
804        &self,
805        session: &mut Session,
806        req: ExecuteRequest,
807        upstream_retry: bamboo_llm::retry::UpstreamRetry,
808    ) -> crate::runtime::runner::Result<()> {
809        session
810            .check_execution_compatibility()
811            .map_err(|error| bamboo_agent_core::AgentError::Tool(error.to_string()))?;
812        let lease = self.begin_direct_execution(&session.id).await?;
813        self.check_direct_execution_admission(session, &lease)
814            .await?;
815        self.prepare_external_session_for_execution(session).await?;
816        self.execute_direct_registered_with_inputs_and_upstream_retry(
817            session,
818            req,
819            lease,
820            None,
821            upstream_retry,
822        )
823        .await
824    }
825
826    /// Check counted-state admission under an already acquired opaque lease.
827    /// SDK facades call this before Project preparation or approved-tool replay.
828    /// This can refuse/fence an interrupted generation; it never replays it.
829    pub fn check_direct_execution_admission<'a>(
830        &'a self,
831        session: &'a mut Session,
832        lease: &'a DirectExecutionLease,
833    ) -> impl std::future::Future<Output = crate::runtime::runner::Result<()>> + Send + 'a {
834        let matches_target = lease.target_session_id == session.id;
835        // Keep the lease borrowed for this future's lifetime without carrying
836        // its non-Sync FnOnce cleanup across await. Only real private proof
837        // enters the Send future; it is revalidated against the current owner.
838        let ownership = lease.registration.as_ref().map(|run| run.ownership());
839        async move {
840            if !matches_target {
841                return Err(bamboo_agent_core::AgentError::LLM(
842                    "direct execution lease target does not match session".into(),
843                ));
844            }
845            session
846                .check_execution_compatibility()
847                .map_err(|error| bamboo_agent_core::AgentError::Tool(error.to_string()))?;
848            super::counted_loop::admit_registered(
849                session,
850                ownership.as_ref(),
851                self.activation_router(),
852                self.persistence().as_ref(),
853            )
854            .await
855        }
856    }
857
858    /// Validate and publish Project/Workspace context for a caller-owned
859    /// session before any external pre-execution side effect.
860    ///
861    /// SDK facades that acquire a direct lease themselves call this before
862    /// approved-tool replay. [`execute_direct`](Self::execute_direct) also calls
863    /// it, so the lower-level escape hatch cannot bypass legacy migration,
864    /// Project validation, or runtime workspace publication.
865    pub async fn prepare_external_session_for_execution(
866        &self,
867        session: &mut Session,
868    ) -> crate::runtime::runner::Result<()> {
869        crate::session_app::execution_prep::prepare_external_session_for_execution(
870            session,
871            self.runtime.project_context_resolver.as_deref(),
872        )
873        .await
874    }
875
876    /// Resolve a proposed SDK Project assignment without publishing a runtime
877    /// workspace. The caller must persist the validated candidate before the
878    /// ordinary execution handoff publishes it or replays an approved tool.
879    pub async fn prepare_external_project_assignment_read_only(
880        &self,
881        session: &mut Session,
882    ) -> crate::runtime::runner::Result<()> {
883        let resolver = self
884            .runtime
885            .project_context_resolver
886            .as_deref()
887            .ok_or_else(|| {
888                bamboo_agent_core::AgentError::ProjectContext(
889                    "Project assignment requires a ProjectContextResolver".to_string(),
890                )
891            })?;
892        resolver
893            .refresh_session_prompt_read_only(session)
894            .await
895            .map(|_| ())
896            .map_err(|error| bamboo_agent_core::AgentError::ProjectContext(error.to_string()))
897    }
898
899    /// Acquire direct logical-session ownership before an SDK facade performs
900    /// pre-execution work such as replaying an approved mutating tool.
901    pub async fn begin_direct_execution(
902        &self,
903        target_session_id: &str,
904    ) -> crate::runtime::runner::Result<DirectExecutionLease> {
905        let Some(router) = self.activation_router().cloned() else {
906            return Ok(DirectExecutionLease {
907                target_session_id: target_session_id.to_string(),
908                router: None,
909                registration: None,
910            });
911        };
912        let run_id = format!("sdk-direct-{}", uuid::Uuid::new_v4());
913        let registration = router
914            .register_run(target_session_id, &run_id)
915            .await
916            .map_err(|error| bamboo_agent_core::AgentError::LLM(error.to_string()))?;
917        Ok(DirectExecutionLease {
918            target_session_id: target_session_id.to_string(),
919            router: Some(router),
920            registration: Some(registration),
921        })
922    }
923
924    /// Execute and finalize a direct run whose ownership was acquired by
925    /// [`begin_direct_execution`](Self::begin_direct_execution).
926    pub async fn execute_direct_registered(
927        &self,
928        session: &mut Session,
929        req: ExecuteRequest,
930        lease: DirectExecutionLease,
931    ) -> crate::runtime::runner::Result<()> {
932        self.execute_direct_registered_with_inputs(session, req, lease, None)
933            .await
934    }
935
936    /// Existing direct lifecycle with a separately owned, untrusted input handoff.
937    pub async fn execute_direct_registered_with_inputs(
938        &self,
939        session: &mut Session,
940        req: ExecuteRequest,
941        lease: DirectExecutionLease,
942        inputs: Option<crate::runtime::config::UntrustedExecutionInputs>,
943    ) -> crate::runtime::runner::Result<()> {
944        self.execute_direct_registered_with_inputs_and_upstream_retry(
945            session,
946            req,
947            lease,
948            inputs,
949            bamboo_llm::retry::UpstreamRetry::Bounded,
950        )
951        .await
952    }
953
954    async fn execute_direct_registered_with_inputs_and_upstream_retry(
955        &self,
956        session: &mut Session,
957        req: ExecuteRequest,
958        lease: DirectExecutionLease,
959        inputs: Option<crate::runtime::config::UntrustedExecutionInputs>,
960        upstream_retry: bamboo_llm::retry::UpstreamRetry,
961    ) -> crate::runtime::runner::Result<()> {
962        self.check_direct_execution_admission(session, &lease)
963            .await?;
964        let inherited = self.persistence().inherited_child_wait().or_else(|| {
965            (!self.runtime.inherited_child_wait_captured)
966                .then(|| bamboo_domain::InheritedChildWait::capture(session))
967                .flatten()
968        });
969        let agent = if let Some(inherited) = inherited {
970            inherited
971                .validate_session(session)
972                .map_err(|error| bamboo_agent_core::AgentError::LLM(error.to_string()))?;
973            if self.persistence().inherited_child_wait().is_some() {
974                self.clone()
975            } else {
976                self.with_execution_persistence(
977                    self.persistence()
978                        .bind_inherited_child_wait(inherited)
979                        .map_err(|error| bamboo_agent_core::AgentError::LLM(error.to_string()))?,
980                )
981            }
982        } else {
983            self.with_execution_persistence(self.persistence().clone())
984        };
985        agent
986            .execute_direct_registered_bound(session, req, lease, inputs, upstream_retry)
987            .await
988    }
989
990    async fn execute_direct_registered_bound(
991        &self,
992        session: &mut Session,
993        mut req: ExecuteRequest,
994        mut lease: DirectExecutionLease,
995        inputs: Option<crate::runtime::config::UntrustedExecutionInputs>,
996        upstream_retry: bamboo_llm::retry::UpstreamRetry,
997    ) -> crate::runtime::runner::Result<()> {
998        if lease.target_session_id != session.id {
999            return Err(bamboo_agent_core::AgentError::LLM(format!(
1000                "direct execution lease target {} does not match session {}",
1001                lease.target_session_id, session.id
1002            )));
1003        }
1004        let Some(router) = lease.router.take() else {
1005            return self
1006                .execute_with_inputs_and_upstream_retry(session, req, inputs, upstream_retry)
1007                .await;
1008        };
1009        let mut registration = lease.registration.take().ok_or_else(|| {
1010            bamboo_agent_core::AgentError::LLM(
1011                "direct execution lease is missing its router registration".to_string(),
1012            )
1013        })?;
1014        if !router
1015            .bind_run_lifetime(&registration.ownership(), session)
1016            .await
1017        {
1018            return Err(bamboo_agent_core::AgentError::Tool(
1019                "registered run Session lifetime differs".into(),
1020            ));
1021        }
1022        if let Some(counted) = req.counted_loop.as_mut() {
1023            counted.bind_owner(registration.ownership(), session);
1024        }
1025        if let Some(control) = req.loop_control.as_mut() {
1026            control.bind_owner(registration.ownership(), session);
1027            control.bind_transport_cleanup(
1028                &mut registration,
1029                self.storage().clone(),
1030                self.session_inbox().cloned(),
1031                self.persistence().root_actor_writer(),
1032            );
1033        }
1034        let result = if req.loop_control.is_some() {
1035            bamboo_agent_core::tools::observe_tool_suspension(false, async {
1036                let token = bamboo_agent_core::tools::capture_suspension_restriction().ok_or_else(
1037                    || {
1038                        bamboo_agent_core::AgentError::Tool(
1039                            "direct loop_control execution is missing its host observation scope"
1040                                .into(),
1041                        )
1042                    },
1043                )?;
1044                // Bind before the runtime is first polled, so cancellation of
1045                // this observer still leaves Drop the same sticky host proof.
1046                registration.bind_tool_suspension_observation(token);
1047                self.execute_with_inputs_and_upstream_retry(session, req, inputs, upstream_retry)
1048                    .await
1049            })
1050            .await
1051            .0
1052        } else {
1053            self.execute_with_inputs_and_upstream_retry(session, req, inputs, upstream_retry)
1054                .await
1055        };
1056        if registration.observed_forbidden_running() {
1057            // No stop receipt exists for this detached outcome, including when
1058            // direct Root execution has no LocalTaskResources domain.
1059            // Retain the exact owner before migration or ordinary finish can
1060            // release it or dispatch a successor. Preserve the runtime error.
1061            registration.quarantine().await;
1062            return match result {
1063                Err(error) => Err(error),
1064                Ok(()) => Err(bamboo_agent_core::AgentError::Tool(
1065                    "suspension_disabled: detached Running outcome; stop unconfirmed".into(),
1066                )),
1067            };
1068        }
1069
1070        // Freeze what this provider execution actually consumed. Compatibility
1071        // migration and concurrent deliveries below must remain newer work.
1072        let executed_admitted_generation = session
1073            .session_inbox_admission()
1074            .map_or(0, |state| state.last_admitted_sequence);
1075        registration.begin_finalization().await;
1076
1077        let legacy_migration = crate::runtime::runner::state_bridge::migrate_legacy_pending_only(
1078            session,
1079            Some(self.storage()),
1080            Some(self.persistence()),
1081            self.session_inbox(),
1082        )
1083        .await;
1084        if let Some(generation) = legacy_migration.highest_generation {
1085            session.session_inbox_admission_mut().observe(generation);
1086        }
1087        let pending_generation = session
1088            .session_inbox_admission()
1089            .and_then(|state| state.pending_activation_generation());
1090        if let Some(generation) = pending_generation {
1091            let activation_ready = if let Some(inbox) = self.session_inbox() {
1092                match inbox
1093                    .mark_activation_eligible(
1094                        &session.id,
1095                        generation,
1096                        bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
1097                    )
1098                    .await
1099                {
1100                    Ok(()) => true,
1101                    Err(error) => {
1102                        tracing::error!(
1103                            session_id = %session.id,
1104                            %error,
1105                            "failed to persist direct SDK SessionInbox activation watermark"
1106                        );
1107                        false
1108                    }
1109                }
1110            } else {
1111                false
1112            };
1113            if activation_ready {
1114                if let Err(error) = bamboo_domain::SessionActivationPort::request_activation(
1115                    router.as_ref(),
1116                    &session.id,
1117                    generation,
1118                )
1119                .await
1120                {
1121                    tracing::error!(
1122                        session_id = %session.id,
1123                        %error,
1124                        "failed to hand direct SDK SessionInbox generation to activation router"
1125                    );
1126                }
1127            }
1128        }
1129
1130        // Keep the owner receiver alive until finalizing is visible. Persist
1131        // the observed-generation marker before a successor can start.
1132        if let Err(error) = self.persistence().checkpoint_runtime_session(session).await {
1133            tracing::warn!(
1134                session_id = %session.id,
1135                %error,
1136                "failed to checkpoint direct SDK terminal SessionInbox state"
1137            );
1138        }
1139        if let Err(error) = registration.finish(executed_admitted_generation).await {
1140            tracing::error!(
1141                session_id = %session.id,
1142                %error,
1143                "direct SDK SessionInbox finalization failed"
1144            );
1145        }
1146
1147        result
1148    }
1149
1150    /// Access the shared storage backend.
1151    pub fn storage(&self) -> &Arc<dyn bamboo_agent_core::storage::Storage> {
1152        &self.runtime.storage
1153    }
1154
1155    /// Access the runtime persistence adapter for non-authoritative saves.
1156    pub fn persistence(&self) -> &Arc<dyn RuntimeSessionPersistence> {
1157        &self.runtime.persistence
1158    }
1159
1160    pub fn session_inbox(&self) -> Option<&Arc<dyn bamboo_domain::SessionInboxPort>> {
1161        self.runtime.session_inbox.as_ref()
1162    }
1163
1164    /// Execute the same durable SessionInbox boundary used by the agent loop
1165    /// before its first provider call. This compatibility method reports only
1166    /// the merge count; it cannot confirm successful ACK. Execution entry points
1167    /// must use [`Self::admit_session_inbox_at_safe_boundary_checked`] instead.
1168    pub async fn admit_session_inbox_at_safe_boundary(
1169        &self,
1170        session: &mut bamboo_agent_core::Session,
1171    ) -> usize {
1172        crate::runtime::runner::state_bridge::refresh_turn_boundary_with_inbox(
1173            session,
1174            Some(self.storage()),
1175            Some(self.persistence()),
1176            self.session_inbox(),
1177        )
1178        .await
1179        .merged
1180    }
1181
1182    /// Confirm the durable admission boundary before entering provider execution.
1183    /// An ACK error is unresolved even after receipt publication. Preserve the
1184    /// checkpoint and existing claim recovery, but reject this activation.
1185    pub async fn admit_session_inbox_at_safe_boundary_checked(
1186        &self,
1187        session: &mut bamboo_agent_core::Session,
1188    ) -> Result<usize, bamboo_agent_core::AgentError> {
1189        let refresh = crate::runtime::runner::state_bridge::refresh_turn_boundary_with_inbox(
1190            session,
1191            Some(self.storage()),
1192            Some(self.persistence()),
1193            self.session_inbox(),
1194        )
1195        .await;
1196        if let Some(error) = refresh.admission_error {
1197            return Err(bamboo_agent_core::AgentError::Tool(error));
1198        }
1199        Ok(refresh.merged)
1200    }
1201
1202    pub fn activation_router(
1203        &self,
1204    ) -> Option<&Arc<crate::session_activation::SessionActivationRouter>> {
1205        self.runtime.activation_router.as_ref()
1206    }
1207
1208    pub fn session_messenger(&self) -> Option<&Arc<crate::SessionMessenger>> {
1209        self.runtime.session_messenger.as_ref()
1210    }
1211
1212    /// Access the runtime's default tool executor (the root/full tool surface
1213    /// assembled at build time).
1214    ///
1215    /// Exposed so callers can compose additional one-off dispatches against the
1216    /// SAME executor the loop itself uses — e.g. re-executing a single
1217    /// previously-gated tool call after a permission approval — without forking
1218    /// or reaching into `AgentLoopConfig` (which stays unconstructible outside
1219    /// the engine). This is a read-only accessor alongside `storage()` /
1220    /// `persistence()`; it does not touch the sealed loop config.
1221    pub fn default_tools(&self) -> &Arc<dyn bamboo_agent_core::tools::ToolExecutor> {
1222        &self.runtime.default_tools
1223    }
1224}
1225
1226// ---------------------------------------------------------------------------
1227// AgentBuilder
1228// ---------------------------------------------------------------------------
1229
1230/// Builder for [`Agent`].
1231///
1232/// Delegates to [`AgentRuntimeBuilder`] internally.
1233pub struct AgentBuilder {
1234    inner: AgentRuntimeBuilder,
1235}
1236
1237impl AgentBuilder {
1238    pub fn new() -> Self {
1239        Self {
1240            inner: AgentRuntimeBuilder::new(),
1241        }
1242    }
1243
1244    pub fn storage(mut self, v: Arc<dyn bamboo_agent_core::storage::Storage>) -> Self {
1245        self.inner = self.inner.storage(v);
1246        self
1247    }
1248
1249    pub fn persistence(mut self, v: Arc<dyn RuntimeSessionPersistence>) -> Self {
1250        self.inner = self.inner.persistence(v);
1251        self
1252    }
1253
1254    pub fn session_inbox(mut self, v: Arc<dyn bamboo_domain::SessionInboxPort>) -> Self {
1255        self.inner = self.inner.session_inbox(v);
1256        self
1257    }
1258
1259    pub fn activation_router(
1260        mut self,
1261        v: Arc<crate::session_activation::SessionActivationRouter>,
1262    ) -> Self {
1263        self.inner = self.inner.activation_router(v);
1264        self
1265    }
1266
1267    pub fn session_messenger(mut self, v: Arc<crate::SessionMessenger>) -> Self {
1268        self.inner = self.inner.session_messenger(v);
1269        self
1270    }
1271
1272    pub fn attachment_reader(
1273        mut self,
1274        v: Arc<dyn bamboo_agent_core::storage::AttachmentReader>,
1275    ) -> Self {
1276        self.inner = self.inner.attachment_reader(v);
1277        self
1278    }
1279
1280    pub fn skill_manager(mut self, v: Arc<bamboo_skills::SkillManager>) -> Self {
1281        self.inner = self.inner.skill_manager(v);
1282        self
1283    }
1284
1285    pub fn project_context_resolver(
1286        mut self,
1287        v: Arc<crate::project_context::ProjectContextResolver>,
1288    ) -> Self {
1289        self.inner = self.inner.project_context_resolver(v);
1290        self
1291    }
1292
1293    pub fn metrics_collector(mut self, v: bamboo_metrics::MetricsCollector) -> Self {
1294        self.inner = self.inner.metrics_collector(v);
1295        self
1296    }
1297
1298    pub fn config(mut self, v: Arc<tokio::sync::RwLock<bamboo_llm::Config>>) -> Self {
1299        self.inner = self.inner.config(v);
1300        self
1301    }
1302
1303    /// Resolve model limits from a host-owned last-known-good section on each
1304    /// round instead of reopening the instance's configuration file.
1305    pub fn model_limits_section(
1306        mut self,
1307        section: Arc<bamboo_config::LiveSection<bamboo_config::ModelLimitsSection>>,
1308    ) -> Self {
1309        self.inner = self.inner.model_limits_section(section);
1310        self
1311    }
1312
1313    pub fn permission_config(mut self, v: Arc<bamboo_tools::permission::PermissionConfig>) -> Self {
1314        self.inner = self.inner.permission_config(v);
1315        self
1316    }
1317
1318    pub fn permission_mode(mut self, v: bamboo_domain::PermissionMode) -> Self {
1319        self.inner = self.inner.permission_mode(v);
1320        self
1321    }
1322
1323    pub fn provider(mut self, v: Arc<dyn bamboo_llm::LLMProvider>) -> Self {
1324        self.inner = self.inner.provider(v);
1325        self
1326    }
1327
1328    pub fn memory_store(mut self, v: bamboo_memory::memory_store::MemoryStore) -> Self {
1329        self.inner = self.inner.memory_store(v);
1330        self
1331    }
1332
1333    pub fn default_tools(mut self, v: Arc<dyn bamboo_agent_core::tools::ToolExecutor>) -> Self {
1334        self.inner = self.inner.default_tools(v);
1335        self
1336    }
1337
1338    /// Install an immutable lifecycle-hook registry for this agent runtime.
1339    pub fn hook_runner(mut self, v: Arc<crate::runtime::HookRunner>) -> Self {
1340        self.inner = self.inner.hook_runner(v);
1341        self
1342    }
1343
1344    pub fn build(self) -> Result<Agent, &'static str> {
1345        let runtime = self.inner.build()?;
1346        Ok(Agent {
1347            runtime: Arc::new(runtime),
1348        })
1349    }
1350}
1351
1352impl Default for AgentBuilder {
1353    fn default() -> Self {
1354        Self::new()
1355    }
1356}
1357
1358#[cfg(test)]
1359mod auxiliary_factory_isolation_tests {
1360    use super::*;
1361    use bamboo_agent_core::storage::Storage;
1362    use bamboo_agent_core::{Message, ToolSchema};
1363    use bamboo_llm::{LLMError, LLMProvider, LLMStream};
1364
1365    struct UnusedProvider;
1366
1367    #[async_trait::async_trait]
1368    impl LLMProvider for UnusedProvider {
1369        async fn chat_stream(
1370            &self,
1371            _messages: &[Message],
1372            _tools: &[ToolSchema],
1373            _max_output_tokens: Option<u32>,
1374            _model: &str,
1375        ) -> Result<LLMStream, LLMError> {
1376            Err(LLMError::Api("provider must not be called".into()))
1377        }
1378    }
1379
1380    #[tokio::test]
1381    async fn factory_clone_discards_only_its_inherited_route_overrides() {
1382        let dir = tempfile::tempdir().unwrap();
1383        let store = Arc::new(
1384            bamboo_storage::SessionStoreV2::new(dir.path().join("sessions"))
1385                .await
1386                .unwrap(),
1387        );
1388        let storage: Arc<dyn Storage> = store.clone();
1389        let persistence: Arc<dyn RuntimeSessionPersistence> =
1390            Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
1391        let metrics = bamboo_metrics::MetricsCollector::spawn(
1392            Arc::new(bamboo_metrics::SqliteMetricsStorage::new(
1393                dir.path().join("metrics.db"),
1394            )),
1395            7,
1396        );
1397        let ordinary = Agent::builder()
1398            .storage(storage)
1399            .persistence(persistence)
1400            .attachment_reader(store)
1401            .skill_manager(Arc::new(bamboo_skills::SkillManager::new()))
1402            .metrics_collector(metrics)
1403            .config(Arc::new(tokio::sync::RwLock::new(
1404                bamboo_llm::Config::default(),
1405            )))
1406            .provider(Arc::new(UnusedProvider))
1407            .default_tools(Arc::new(bamboo_tools::BuiltinToolExecutor::new()))
1408            .build()
1409            .unwrap();
1410        let background: crate::runtime::config::BackgroundModelResolver = Arc::new(|_| None);
1411        let resume: crate::runtime::config::ResumeAuxiliaryResolver = Arc::new(|| (Ok(None), None));
1412        let bound = ordinary
1413            .with_background_model_resolver(background.clone())
1414            .with_resume_auxiliary_resolver(resume.clone());
1415        let sibling = bound.clone();
1416        let schedule = bound.with_auxiliary_model_resolver_factory(Arc::new(|key| {
1417            let key = key.to_owned();
1418            Arc::new(move || crate::AuxiliaryModelConfig {
1419                fast_model_name: Some(key.clone()),
1420                ..Default::default()
1421            })
1422        }));
1423
1424        assert!(ordinary.runtime.auxiliary_model_resolver_factory.is_none());
1425        assert!(ordinary.runtime.background_model_resolver.is_none());
1426        assert!(ordinary.runtime.resume_auxiliary_resolver.is_none());
1427        for agent in [&bound, &sibling] {
1428            assert!(agent.runtime.auxiliary_model_resolver_factory.is_none());
1429            assert!(Arc::ptr_eq(
1430                agent.runtime.background_model_resolver.as_ref().unwrap(),
1431                &background,
1432            ));
1433            assert!(Arc::ptr_eq(
1434                agent.runtime.resume_auxiliary_resolver.as_ref().unwrap(),
1435                &resume,
1436            ));
1437        }
1438        assert!(schedule.runtime.background_model_resolver.is_none());
1439        assert!(schedule.runtime.resume_auxiliary_resolver.is_none());
1440        let factory = schedule
1441            .runtime
1442            .auxiliary_model_resolver_factory
1443            .as_ref()
1444            .unwrap();
1445        assert_eq!(
1446            factory("run-key")().fast_model_name.as_deref(),
1447            Some("run-key")
1448        );
1449    }
1450}
1451
1452#[cfg(test)]
1453#[path = "child_resume_gold_policy_tests.rs"]
1454mod child_resume_gold_policy_tests;