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    /// Stage compaction with the current Host configuration and the Session's
400    /// existing model route. The caller must commit under its own idle writer.
401    pub async fn stage_context_compaction(
402        &self,
403        session: &mut Session,
404        registry: &Arc<bamboo_llm::ProviderRegistry>,
405        tools: &dyn bamboo_agent_core::tools::ToolExecutor,
406        app_data_dir: &std::path::Path,
407    ) -> crate::runtime::runner::Result<bool> {
408        use crate::model_config_helper::{
409            get_default_model_for_provider, get_reasoning_effort_for_provider, resolve_gold_config,
410            resolve_live_image_fallback, resolve_provider_routing_key, resolve_provider_type,
411            try_resolve_primary_model, GOLD_CONFIG_METADATA_KEY,
412        };
413        use crate::runtime::config::AgentLoopConfig;
414        use crate::runtime::runner::image_fallback;
415        use crate::session_app::execute::resolve_execution_model;
416        use crate::session_app::types::{ExecuteInput, ExecutionConfigSnapshot};
417        use bamboo_agent_core::AgentError;
418
419        let config = self.runtime.config.read().await.clone();
420        let default_provider =
421            resolve_provider_routing_key(&config, config.effective_default_provider(), registry);
422        let (target, model, _) = resolve_execution_model(
423            session,
424            &ExecuteInput::default(),
425            &ExecutionConfigSnapshot {
426                default_model: default_provider
427                    .as_ref()
428                    .ok()
429                    .and_then(|key| get_default_model_for_provider(&config, key).ok()),
430                default_model_ref: config
431                    .defaults
432                    .as_ref()
433                    .map(|defaults| defaults.chat.clone()),
434                provider_model_ref_enabled: config.features.provider_model_ref,
435                ..Default::default()
436            },
437        )
438        .map_err(|error| AgentError::LLM(error.to_string()))?;
439        let target = match target {
440            Some(target) => target,
441            None if session.metadata.contains_key("created_by_schedule_id") => {
442                return Err(AgentError::LLM(
443                    "Scheduled session has no provider/model target".into(),
444                ));
445            }
446            None => bamboo_domain::ProviderModelRef::new(
447                default_provider.map_err(|error| AgentError::LLM(error.to_string()))?,
448                model
449                    .ok_or_else(|| AgentError::LLM("A model is required for compaction".into()))?,
450            ),
451        };
452        let selected = try_resolve_primary_model(&config, registry, &target)
453            .map_err(|error| AgentError::LLM(error.to_string()))?;
454        let provider_name = &selected.target.provider;
455        let model = &selected.target.model;
456        let llm = &selected.provider;
457        let data_dir = Some(app_data_dir.to_path_buf());
458        let fallback = resolve_live_image_fallback(&config).map_err(AgentError::LLM)?;
459        let legacy_vision_model_resolver = fallback
460            .as_ref()
461            .filter(|fallback| fallback.mode == crate::ImageFallbackMode::Vision)
462            .map(|fallback| {
463                let source = bamboo_llm::provider_registry::LegacyVisionProviderSource::capture(
464                    &config,
465                    provider_name,
466                    llm.clone(),
467                    selected.managed_origin.clone(),
468                )
469                .ok()
470                .map(Arc::new);
471                image_fallback::legacy_vision_resolver(
472                    config.clone(),
473                    provider_name.clone(),
474                    llm.clone(),
475                    fallback.vision_model.clone(),
476                    data_dir.clone(),
477                    true,
478                    source,
479                )
480            });
481        let memory = config.memory();
482        let loop_config = AgentLoopConfig {
483            legacy_model_limits: config.extra.get("model_limits").cloned(),
484            model_limits_section: self.runtime.model_limits_section.clone(),
485            attachment_reader: Some(self.runtime.attachment_reader.clone()),
486            model_name: Some(model.clone()),
487            provider_name: Some(provider_name.clone()),
488            provider_type: resolve_provider_type(&config, provider_name, registry),
489            reasoning_effort: session
490                .reasoning_effort
491                .or(target.reasoning_effort)
492                .or_else(|| get_reasoning_effort_for_provider(&config, provider_name)),
493            context_management: config.context_management.clone(),
494            gold_config: resolve_gold_config(
495                &config,
496                session
497                    .metadata
498                    .get(GOLD_CONFIG_METADATA_KEY)
499                    .map(String::as_str),
500            ),
501            summary_target_ratio: memory.as_ref().map_or(0.20, |m| m.summary_target_ratio),
502            summary_safe_window_percent: memory
503                .as_ref()
504                .map_or(80, |m| m.summary_safe_window_percent),
505            auxiliary_model_resolver: Some(crate::model_areas::live_auxiliary_model_resolver(
506                self.runtime.config.clone(),
507                registry.clone(),
508                provider_name.clone(),
509            )),
510            disabled_tools: config.disabled_tool_references(),
511            disabled_skill_ids: config.disabled_skill_ids(),
512            image_fallback: fallback,
513            legacy_vision_model_resolver,
514            vision_model_resolver: Some(image_fallback::configured_vision_resolver(
515                self.runtime.config.clone(),
516                data_dir.clone(),
517                provider_name.clone(),
518            )),
519            hook_runner: Arc::new(
520                self.runtime
521                    .hook_runner
522                    .with_lifecycle_config(&config.lifecycle_hooks, data_dir.clone()),
523            ),
524            app_data_dir: data_dir,
525            ..Default::default()
526        };
527        let tools = crate::runtime::runner::session_setup::tool_schemas::resolve_available_tool_schemas_for_session(
528            &loop_config, tools, session,
529        );
530        crate::runtime::runner::round_lifecycle::stage_user_context_compaction(
531            session,
532            &loop_config,
533            model,
534            &tools,
535            llm,
536        )
537        .await
538    }
539
540    /// Wrap an existing [`AgentRuntime`] in an `Agent`.
541    pub fn from_runtime(runtime: Arc<AgentRuntime>) -> Self {
542        Agent { runtime }
543    }
544
545    /// Return only the Host's still-current explicitly injected provider.
546    #[doc(hidden)]
547    pub fn legacy_injected_provider_snapshot(
548        &self,
549    ) -> Result<Option<Arc<dyn bamboo_llm::LLMProvider>>, bamboo_llm::LLMError> {
550        self.runtime.provider.legacy_injected_provider_snapshot()
551    }
552
553    /// Freeze the same verified injection and retain its Host budget identity.
554    #[doc(hidden)]
555    pub fn fixed_legacy_injected_provider(
556        &self,
557        expected: &Arc<dyn bamboo_llm::LLMProvider>,
558    ) -> Result<Option<Arc<dyn bamboo_llm::LLMProvider>>, bamboo_llm::LLMError> {
559        Ok(self
560            .runtime
561            .provider
562            .legacy_injected_provider_snapshot()?
563            .and_then(|current| {
564                Arc::ptr_eq(&current, expected).then(|| {
565                    bamboo_llm::provider::fixed_injected_provider_with_identity(
566                        current,
567                        self.runtime.provider.clone(),
568                    )
569                })
570            }))
571    }
572
573    /// One execution's immutable persistence capability. Shared tool/provider
574    /// resources remain on the existing runtime; default callers stay unbound.
575    #[doc(hidden)]
576    pub fn with_execution_persistence(
577        &self,
578        persistence: Arc<dyn RuntimeSessionPersistence>,
579    ) -> Self {
580        let mut runtime = (*self.runtime).clone();
581        runtime.persistence = persistence;
582        runtime.inherited_child_wait_captured = true;
583        Self::from_runtime(Arc::new(runtime))
584    }
585
586    /// Mark this execution's legacy image fallback as derived by the Host
587    /// from configuration. The shared agent and caller-owned runs stay unchanged.
588    #[doc(hidden)]
589    pub fn with_config_derived_image_fallback(&self) -> Self {
590        let mut runtime = (*self.runtime).clone();
591        runtime.legacy_image_from_config = true;
592        Self::from_runtime(Arc::new(runtime))
593    }
594
595    /// Replace the per-run evidence for the selected legacy Vision client.
596    #[doc(hidden)]
597    pub fn with_legacy_vision_provider_source(
598        &self,
599        source: Option<Arc<bamboo_llm::provider_registry::LegacyVisionProviderSource>>,
600    ) -> Self {
601        let mut runtime = (*self.runtime).clone();
602        runtime.legacy_image_provider_source = source;
603        Self::from_runtime(Arc::new(runtime))
604    }
605
606    /// Adapt the default provider for this execution while retaining every
607    /// other runtime capability. Explicit request-level overrides stay separate.
608    #[doc(hidden)]
609    pub fn with_default_provider_adapter(
610        &self,
611        adapter: impl FnOnce(Arc<dyn bamboo_llm::LLMProvider>) -> Arc<dyn bamboo_llm::LLMProvider>,
612    ) -> Self {
613        let mut runtime = (*self.runtime).clone();
614        runtime.provider = adapter(runtime.provider.clone());
615        Self::from_runtime(Arc::new(runtime))
616    }
617
618    /// Bind a live-bootstrap Host to this execution only. The Host must retain
619    /// its exact runner and writer; this method does not create authority.
620    #[doc(hidden)]
621    pub fn with_live_bootstrap_host(
622        &self,
623        host: Arc<dyn crate::runtime::config::LiveBootstrapHost>,
624    ) -> Self {
625        let mut runtime = (*self.runtime).clone();
626        runtime.live_bootstrap_host = Some(host);
627        Self::from_runtime(Arc::new(runtime))
628    }
629
630    /// Bind a live background route to this execution without changing other roles.
631    #[doc(hidden)]
632    pub fn with_background_model_resolver(
633        &self,
634        resolver: crate::runtime::config::BackgroundModelResolver,
635    ) -> Self {
636        let mut runtime = (*self.runtime).clone();
637        runtime.background_model_resolver = Some(resolver);
638        Self::from_runtime(Arc::new(runtime))
639    }
640
641    /// Install a run-keyed auxiliary resolver on this Agent clone only.
642    #[doc(hidden)]
643    pub fn with_auxiliary_model_resolver_factory(
644        &self,
645        factory: Arc<
646            dyn Fn(&str) -> Arc<dyn Fn() -> crate::AuxiliaryModelConfig + Send + Sync>
647                + Send
648                + Sync,
649        >,
650    ) -> Self {
651        let mut runtime = (*self.runtime).clone();
652        runtime.auxiliary_model_resolver_factory = Some(factory);
653        // The factory resolves background and summary with the other roles.
654        // Inherited overrides could belong to another run and would otherwise
655        // replace those selections with a second config read.
656        runtime.background_model_resolver = None;
657        runtime.resume_auxiliary_resolver = None;
658        Self::from_runtime(Arc::new(runtime))
659    }
660
661    /// Bind a resumed Connect run's live summary/background selection.
662    #[doc(hidden)]
663    pub fn with_resume_auxiliary_resolver(
664        &self,
665        resolver: crate::runtime::config::ResumeAuxiliaryResolver,
666    ) -> Self {
667        let mut runtime = (*self.runtime).clone();
668        runtime.resume_auxiliary_resolver = Some(resolver);
669        Self::from_runtime(Arc::new(runtime))
670    }
671
672    /// Opt a Host-owned non-HTTP consumer into per-attempt Gold provenance.
673    /// Direct SDK Agents keep their existing provider contract.
674    #[doc(hidden)]
675    pub fn with_host_gold_origin_resolver(
676        &self,
677        resolver: crate::runtime::host_gold::HostGoldOriginResolver,
678    ) -> Self {
679        let mut runtime = (*self.runtime).clone();
680        runtime.host_gold_origin_resolver = Some(resolver);
681        Self::from_runtime(Arc::new(runtime))
682    }
683
684    /// Bind the Host's Gold route to this execution without changing SDK defaults.
685    /// For managed child resume the synchronous resolver may be called again
686    /// at preflight and dispatch; it must not initiate a request or block.
687    #[doc(hidden)]
688    pub fn with_gold_target_resolver(
689        &self,
690        resolver: crate::runtime::config::GoldTargetResolver,
691    ) -> Self {
692        let mut runtime = (*self.runtime).clone();
693        runtime.gold_target_resolver = Some(resolver);
694        runtime.child_resume_gold_selected = None;
695        Self::from_runtime(Arc::new(runtime))
696    }
697
698    /// Add a managed Host policy while retaining any inherited Gold policy.
699    #[doc(hidden)]
700    pub fn with_composed_gold_target_resolver(
701        &self,
702        resolver: crate::runtime::config::GoldTargetResolver,
703    ) -> Self {
704        let mut runtime = (*self.runtime).clone();
705        runtime.gold_target_resolver = Some(compose_child_gold_policy(
706            runtime.gold_target_resolver.take(),
707            resolver,
708        ));
709        Self::from_runtime(Arc::new(runtime))
710    }
711
712    // Only managed child resume opts into an exact selected-main check. SDK
713    // and other Host resolvers retain their existing delegate behavior.
714    pub(crate) fn with_child_resume_gold_target_resolver(
715        &self,
716        selected: Arc<dyn bamboo_llm::LLMProvider>,
717        resolver: crate::runtime::config::GoldTargetResolver,
718    ) -> Self {
719        let mut runtime = (*self.runtime).clone();
720        runtime.gold_target_resolver = Some(compose_child_gold_policy(
721            runtime.gold_target_resolver.take(),
722            resolver,
723        ));
724        runtime.child_resume_gold_selected = Some(selected);
725        Self::from_runtime(Arc::new(runtime))
726    }
727
728    /// Install the registered SDK adapter on this execution's runtime only.
729    #[doc(hidden)]
730    pub fn with_sdk_skill_execution_host(
731        &self,
732        host: Arc<dyn crate::runtime::config::SdkSkillExecutionHost>,
733    ) -> Self {
734        self.with_skill_execution_host(host)
735    }
736
737    /// Attach an execution-local registered host; this creates no caller authority.
738    #[doc(hidden)]
739    pub fn with_skill_execution_host(
740        &self,
741        host: Arc<dyn crate::runtime::config::SkillExecutionHost>,
742    ) -> Self {
743        let mut runtime = (*self.runtime).clone();
744        runtime.sdk_skill_execution_host = Some(host);
745        Self::from_runtime(Arc::new(runtime))
746    }
747
748    /// Bind Workspace adoption to this execution without opting into Skill mode.
749    #[doc(hidden)]
750    pub fn with_workspace_execution_host(
751        &self,
752        host: Arc<dyn crate::runtime::config::WorkspaceExecutionHost>,
753    ) -> Self {
754        let mut runtime = (*self.runtime).clone();
755        runtime.workspace_execution_host = Some(host);
756        Self::from_runtime(Arc::new(runtime))
757    }
758
759    /// Run the genuine portable submission hook before F and append. No old
760    /// metadata receipt may skip this new submission's hook.
761    #[doc(hidden)]
762    pub async fn precheck_sdk_user_input(
763        &self,
764        session: &mut Session,
765        user: &bamboo_agent_core::Message,
766        app_data_dir: Option<std::path::PathBuf>,
767    ) -> crate::runtime::runner::Result<SdkInputSubmission> {
768        session
769            .check_execution_compatibility()
770            .map_err(|error| bamboo_agent_core::AgentError::Tool(error.to_string()))?;
771        if user.role != bamboo_agent_core::Role::User
772            || crate::runtime::config::UntrustedInputObservation::new(&user.id, None).is_none()
773            || session.messages.iter().any(|message| message.id == user.id)
774        {
775            return Err(bamboo_agent_core::AgentError::Tool(
776                "invalid new SDK User".into(),
777            ));
778        }
779        let hook_runner = {
780            let config = self.runtime.config.read().await;
781            Arc::new(
782                self.runtime
783                    .hook_runner
784                    .with_lifecycle_config(&config.lifecycle_hooks, app_data_dir),
785            )
786        };
787        session.metadata.remove("runtime.plugin_prompt_prechecked");
788        let prompt = hook_runner
789            .apply_portable_user_prompt(session, &user.content)
790            .await?;
791        let mut transformed = user.clone();
792        transformed.content = prompt.clone();
793        if let Some(parts) = transformed.content_parts.as_mut() {
794            if let Some(bamboo_domain::MessagePart::Text { text }) =
795                parts.iter_mut().find(|part| matches!(part, bamboo_domain::MessagePart::Text { text } if text == &user.content))
796            {
797                *text = prompt;
798            }
799        }
800        Ok(SdkInputSubmission {
801            session_id: session.id.clone(),
802            execution_id: crate::runtime::runner::round_prelude::new_execution_id(),
803            user: transformed,
804            hook_runner,
805        })
806    }
807
808    /// Actual SDK producer append. The private pending receipt becomes checked
809    /// only after startup hooks and a durable same-repository checkpoint.
810    #[doc(hidden)]
811    pub fn append_sdk_user_input(
812        &self,
813        session: &mut Session,
814        user: bamboo_agent_core::Message,
815        receipt: Option<SdkInputSubmission>,
816        request: Option<bamboo_domain::SessionSkillRequest>,
817    ) -> crate::runtime::runner::Result<crate::runtime::config::UntrustedExecutionInputs> {
818        session
819            .check_execution_compatibility()
820            .map_err(|error| bamboo_agent_core::AgentError::Tool(error.to_string()))?;
821        use crate::runtime::config::{
822            sdk_message_digest, sdk_message_header_digest, SdkPendingInput,
823            UntrustedExecutionInputs, UntrustedInputObservation,
824        };
825        let invalid = || bamboo_agent_core::AgentError::Tool("invalid SDK append receipt".into());
826        let header_digest = sdk_message_header_digest(&user);
827        let input_id = user.id.clone();
828        if let Some(receipt) = receipt.as_ref() {
829            if receipt.session_id != session.id
830                || receipt.user.role != bamboo_agent_core::Role::User
831                || user.role != bamboo_agent_core::Role::User
832                || sdk_message_header_digest(&receipt.user) != header_digest
833                || session
834                    .messages
835                    .iter()
836                    .any(|message| message.id == input_id)
837            {
838                return Err(invalid());
839            }
840        }
841        // Preserve the legacy String append even when optional observation data
842        // is malformed. Typed callers fail before append on an invalid receipt.
843        let final_digest = receipt.as_ref().map(|_| sdk_message_digest(&user));
844        if receipt.is_some() {
845            crate::runtime::hooks::HookRunner::mark_user_prompt_prechecked(session, &user.content);
846        }
847        session.add_message(user);
848        let observation =
849            UntrustedInputObservation::new(&input_id, request.as_ref()).ok_or_else(invalid)?;
850        let carrier = UntrustedExecutionInputs::new(vec![observation]).ok_or_else(invalid)?;
851        let (execution_id, hook_runner) = receipt
852            .map(|receipt| (receipt.execution_id, Some(receipt.hook_runner)))
853            .unwrap_or_else(|| {
854                (
855                    crate::runtime::runner::round_prelude::new_execution_id(),
856                    None,
857                )
858            });
859        Ok(carrier.bind_sdk_append(
860            execution_id,
861            SdkPendingInput {
862                session_id: session.id.clone(),
863                input_id,
864                header_digest,
865                final_digest,
866            },
867            hook_runner,
868        ))
869    }
870
871    /// Return a new builder.
872    pub fn builder() -> AgentBuilder {
873        AgentBuilder::new()
874    }
875
876    /// Execute the agent loop with the given request.
877    pub async fn execute(
878        &self,
879        session: &mut Session,
880        req: ExecuteRequest,
881    ) -> crate::runtime::runner::Result<()> {
882        self.runtime.execute(session, req).await
883    }
884
885    /// Execute with an explicit initial upstream request policy.
886    pub async fn execute_with_upstream_retry(
887        &self,
888        session: &mut Session,
889        req: ExecuteRequest,
890        upstream_retry: bamboo_llm::retry::UpstreamRetry,
891    ) -> crate::runtime::runner::Result<()> {
892        self.runtime
893            .execute_with_upstream_retry(session, req, upstream_retry)
894            .await
895    }
896
897    /// Explicit bounded caller-data handoff; no currentness or Skill authority.
898    /// Ordinary execute/direct/spawn entrypoints continue to supply None.
899    pub async fn execute_with_inputs(
900        &self,
901        session: &mut Session,
902        req: ExecuteRequest,
903        inputs: Option<crate::runtime::config::UntrustedExecutionInputs>,
904    ) -> crate::runtime::runner::Result<()> {
905        self.runtime.execute_with_inputs(session, req, inputs).await
906    }
907
908    pub(crate) async fn execute_with_inputs_and_upstream_retry(
909        &self,
910        session: &mut Session,
911        req: ExecuteRequest,
912        inputs: Option<crate::runtime::config::UntrustedExecutionInputs>,
913        upstream_retry: bamboo_llm::retry::UpstreamRetry,
914    ) -> crate::runtime::runner::Result<()> {
915        self.runtime
916            .execute_with_inputs_and_upstream_retry(session, req, inputs, upstream_retry)
917            .await
918    }
919
920    /// Execute a caller-owned session under a complete logical-session
921    /// activation lifecycle.
922    ///
923    /// Server/child entry points already own an external runner reservation and
924    /// therefore use [`execute`](Self::execute) plus their existing terminal
925    /// handshake. Direct SDK callers have no runner registry, so this wrapper
926    /// registers the current run before entering the provider loop, marks it
927    /// finalizing immediately after return, migrates any terminal-window legacy
928    /// ingress, and lets the router reserve at most one successor for work the
929    /// completed reasoning turn did not admit.
930    pub async fn execute_direct(
931        &self,
932        session: &mut Session,
933        req: ExecuteRequest,
934    ) -> crate::runtime::runner::Result<()> {
935        self.execute_direct_with_upstream_retry(
936            session,
937            req,
938            bamboo_llm::retry::UpstreamRetry::Bounded,
939        )
940        .await
941    }
942
943    /// Execute through the direct owner lifecycle with an explicit retry policy.
944    pub async fn execute_direct_with_upstream_retry(
945        &self,
946        session: &mut Session,
947        req: ExecuteRequest,
948        upstream_retry: bamboo_llm::retry::UpstreamRetry,
949    ) -> crate::runtime::runner::Result<()> {
950        session
951            .check_execution_compatibility()
952            .map_err(|error| bamboo_agent_core::AgentError::Tool(error.to_string()))?;
953        let lease = self.begin_direct_execution(&session.id).await?;
954        self.check_direct_execution_admission(session, &lease)
955            .await?;
956        self.prepare_external_session_for_execution(session).await?;
957        self.execute_direct_registered_with_inputs_and_upstream_retry(
958            session,
959            req,
960            lease,
961            None,
962            upstream_retry,
963        )
964        .await
965    }
966
967    /// Check counted-state admission under an already acquired opaque lease.
968    /// SDK facades call this before Project preparation or approved-tool replay.
969    /// This can refuse/fence an interrupted generation; it never replays it.
970    pub fn check_direct_execution_admission<'a>(
971        &'a self,
972        session: &'a mut Session,
973        lease: &'a DirectExecutionLease,
974    ) -> impl std::future::Future<Output = crate::runtime::runner::Result<()>> + Send + 'a {
975        let matches_target = lease.target_session_id == session.id;
976        // Keep the lease borrowed for this future's lifetime without carrying
977        // its non-Sync FnOnce cleanup across await. Only real private proof
978        // enters the Send future; it is revalidated against the current owner.
979        let ownership = lease.registration.as_ref().map(|run| run.ownership());
980        async move {
981            if !matches_target {
982                return Err(bamboo_agent_core::AgentError::LLM(
983                    "direct execution lease target does not match session".into(),
984                ));
985            }
986            session
987                .check_execution_compatibility()
988                .map_err(|error| bamboo_agent_core::AgentError::Tool(error.to_string()))?;
989            super::counted_loop::admit_registered(
990                session,
991                ownership.as_ref(),
992                self.activation_router(),
993                self.persistence().as_ref(),
994            )
995            .await
996        }
997    }
998
999    /// Validate and publish Project/Workspace context for a caller-owned
1000    /// session before any external pre-execution side effect.
1001    ///
1002    /// SDK facades that acquire a direct lease themselves call this before
1003    /// approved-tool replay. [`execute_direct`](Self::execute_direct) also calls
1004    /// it, so the lower-level escape hatch cannot bypass legacy migration,
1005    /// Project validation, or runtime workspace publication.
1006    pub async fn prepare_external_session_for_execution(
1007        &self,
1008        session: &mut Session,
1009    ) -> crate::runtime::runner::Result<()> {
1010        crate::session_app::execution_prep::prepare_external_session_for_execution(
1011            session,
1012            self.runtime.project_context_resolver.as_deref(),
1013        )
1014        .await
1015    }
1016
1017    /// Resolve a proposed SDK Project assignment without publishing a runtime
1018    /// workspace. The caller must persist the validated candidate before the
1019    /// ordinary execution handoff publishes it or replays an approved tool.
1020    pub async fn prepare_external_project_assignment_read_only(
1021        &self,
1022        session: &mut Session,
1023    ) -> crate::runtime::runner::Result<()> {
1024        let resolver = self
1025            .runtime
1026            .project_context_resolver
1027            .as_deref()
1028            .ok_or_else(|| {
1029                bamboo_agent_core::AgentError::ProjectContext(
1030                    "Project assignment requires a ProjectContextResolver".to_string(),
1031                )
1032            })?;
1033        resolver
1034            .refresh_session_prompt_read_only(session)
1035            .await
1036            .map(|_| ())
1037            .map_err(|error| bamboo_agent_core::AgentError::ProjectContext(error.to_string()))
1038    }
1039
1040    /// Acquire direct logical-session ownership before an SDK facade performs
1041    /// pre-execution work such as replaying an approved mutating tool.
1042    pub async fn begin_direct_execution(
1043        &self,
1044        target_session_id: &str,
1045    ) -> crate::runtime::runner::Result<DirectExecutionLease> {
1046        let Some(router) = self.activation_router().cloned() else {
1047            return Ok(DirectExecutionLease {
1048                target_session_id: target_session_id.to_string(),
1049                router: None,
1050                registration: None,
1051            });
1052        };
1053        let run_id = format!("sdk-direct-{}", uuid::Uuid::new_v4());
1054        let registration = router
1055            .register_run(target_session_id, &run_id)
1056            .await
1057            .map_err(|error| bamboo_agent_core::AgentError::LLM(error.to_string()))?;
1058        Ok(DirectExecutionLease {
1059            target_session_id: target_session_id.to_string(),
1060            router: Some(router),
1061            registration: Some(registration),
1062        })
1063    }
1064
1065    /// Execute and finalize a direct run whose ownership was acquired by
1066    /// [`begin_direct_execution`](Self::begin_direct_execution).
1067    pub async fn execute_direct_registered(
1068        &self,
1069        session: &mut Session,
1070        req: ExecuteRequest,
1071        lease: DirectExecutionLease,
1072    ) -> crate::runtime::runner::Result<()> {
1073        self.execute_direct_registered_with_inputs(session, req, lease, None)
1074            .await
1075    }
1076
1077    /// Existing direct lifecycle with a separately owned, untrusted input handoff.
1078    pub async fn execute_direct_registered_with_inputs(
1079        &self,
1080        session: &mut Session,
1081        req: ExecuteRequest,
1082        lease: DirectExecutionLease,
1083        inputs: Option<crate::runtime::config::UntrustedExecutionInputs>,
1084    ) -> crate::runtime::runner::Result<()> {
1085        self.execute_direct_registered_with_inputs_and_upstream_retry(
1086            session,
1087            req,
1088            lease,
1089            inputs,
1090            bamboo_llm::retry::UpstreamRetry::Bounded,
1091        )
1092        .await
1093    }
1094
1095    async fn execute_direct_registered_with_inputs_and_upstream_retry(
1096        &self,
1097        session: &mut Session,
1098        req: ExecuteRequest,
1099        lease: DirectExecutionLease,
1100        inputs: Option<crate::runtime::config::UntrustedExecutionInputs>,
1101        upstream_retry: bamboo_llm::retry::UpstreamRetry,
1102    ) -> crate::runtime::runner::Result<()> {
1103        self.check_direct_execution_admission(session, &lease)
1104            .await?;
1105        let inherited = self.persistence().inherited_child_wait().or_else(|| {
1106            (!self.runtime.inherited_child_wait_captured)
1107                .then(|| bamboo_domain::InheritedChildWait::capture(session))
1108                .flatten()
1109        });
1110        let agent = if let Some(inherited) = inherited {
1111            inherited
1112                .validate_session(session)
1113                .map_err(|error| bamboo_agent_core::AgentError::LLM(error.to_string()))?;
1114            if self.persistence().inherited_child_wait().is_some() {
1115                self.clone()
1116            } else {
1117                self.with_execution_persistence(
1118                    self.persistence()
1119                        .bind_inherited_child_wait(inherited)
1120                        .map_err(|error| bamboo_agent_core::AgentError::LLM(error.to_string()))?,
1121                )
1122            }
1123        } else {
1124            self.with_execution_persistence(self.persistence().clone())
1125        };
1126        agent
1127            .execute_direct_registered_bound(session, req, lease, inputs, upstream_retry)
1128            .await
1129    }
1130
1131    async fn execute_direct_registered_bound(
1132        &self,
1133        session: &mut Session,
1134        mut req: ExecuteRequest,
1135        mut lease: DirectExecutionLease,
1136        inputs: Option<crate::runtime::config::UntrustedExecutionInputs>,
1137        upstream_retry: bamboo_llm::retry::UpstreamRetry,
1138    ) -> crate::runtime::runner::Result<()> {
1139        if lease.target_session_id != session.id {
1140            return Err(bamboo_agent_core::AgentError::LLM(format!(
1141                "direct execution lease target {} does not match session {}",
1142                lease.target_session_id, session.id
1143            )));
1144        }
1145        let Some(router) = lease.router.take() else {
1146            return self
1147                .execute_with_inputs_and_upstream_retry(session, req, inputs, upstream_retry)
1148                .await;
1149        };
1150        let mut registration = lease.registration.take().ok_or_else(|| {
1151            bamboo_agent_core::AgentError::LLM(
1152                "direct execution lease is missing its router registration".to_string(),
1153            )
1154        })?;
1155        if !router
1156            .bind_run_lifetime(&registration.ownership(), session)
1157            .await
1158        {
1159            return Err(bamboo_agent_core::AgentError::Tool(
1160                "registered run Session lifetime differs".into(),
1161            ));
1162        }
1163        if let Some(counted) = req.counted_loop.as_mut() {
1164            counted.bind_owner(registration.ownership(), session);
1165        }
1166        if let Some(control) = req.loop_control.as_mut() {
1167            control.bind_owner(registration.ownership(), session);
1168            control.bind_transport_cleanup(
1169                &mut registration,
1170                self.storage().clone(),
1171                self.session_inbox().cloned(),
1172                self.persistence().root_actor_writer(),
1173            );
1174        }
1175        let result = if req.loop_control.is_some() {
1176            bamboo_agent_core::tools::observe_tool_suspension(false, async {
1177                let token = bamboo_agent_core::tools::capture_suspension_restriction().ok_or_else(
1178                    || {
1179                        bamboo_agent_core::AgentError::Tool(
1180                            "direct loop_control execution is missing its host observation scope"
1181                                .into(),
1182                        )
1183                    },
1184                )?;
1185                // Bind before the runtime is first polled, so cancellation of
1186                // this observer still leaves Drop the same sticky host proof.
1187                registration.bind_tool_suspension_observation(token);
1188                self.execute_with_inputs_and_upstream_retry(session, req, inputs, upstream_retry)
1189                    .await
1190            })
1191            .await
1192            .0
1193        } else {
1194            self.execute_with_inputs_and_upstream_retry(session, req, inputs, upstream_retry)
1195                .await
1196        };
1197        if registration.observed_forbidden_running() {
1198            // No stop receipt exists for this detached outcome, including when
1199            // direct Root execution has no LocalTaskResources domain.
1200            // Retain the exact owner before migration or ordinary finish can
1201            // release it or dispatch a successor. Preserve the runtime error.
1202            registration.quarantine().await;
1203            return match result {
1204                Err(error) => Err(error),
1205                Ok(()) => Err(bamboo_agent_core::AgentError::Tool(
1206                    "suspension_disabled: detached Running outcome; stop unconfirmed".into(),
1207                )),
1208            };
1209        }
1210
1211        // Freeze what this provider execution actually consumed. Compatibility
1212        // migration and concurrent deliveries below must remain newer work.
1213        let executed_admitted_generation = session
1214            .session_inbox_admission()
1215            .map_or(0, |state| state.last_admitted_sequence);
1216        registration.begin_finalization().await;
1217
1218        let legacy_migration = crate::runtime::runner::state_bridge::migrate_legacy_pending_only(
1219            session,
1220            Some(self.storage()),
1221            Some(self.persistence()),
1222            self.session_inbox(),
1223        )
1224        .await;
1225        if let Some(generation) = legacy_migration.highest_generation {
1226            session.session_inbox_admission_mut().observe(generation);
1227        }
1228        let pending_generation = session
1229            .session_inbox_admission()
1230            .and_then(|state| state.pending_activation_generation());
1231        if let Some(generation) = pending_generation {
1232            let activation_ready = if let Some(inbox) = self.session_inbox() {
1233                match inbox
1234                    .mark_activation_eligible(
1235                        &session.id,
1236                        generation,
1237                        bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
1238                    )
1239                    .await
1240                {
1241                    Ok(()) => true,
1242                    Err(error) => {
1243                        tracing::error!(
1244                            session_id = %session.id,
1245                            %error,
1246                            "failed to persist direct SDK SessionInbox activation watermark"
1247                        );
1248                        false
1249                    }
1250                }
1251            } else {
1252                false
1253            };
1254            if activation_ready {
1255                if let Err(error) = bamboo_domain::SessionActivationPort::request_activation(
1256                    router.as_ref(),
1257                    &session.id,
1258                    generation,
1259                )
1260                .await
1261                {
1262                    tracing::error!(
1263                        session_id = %session.id,
1264                        %error,
1265                        "failed to hand direct SDK SessionInbox generation to activation router"
1266                    );
1267                }
1268            }
1269        }
1270
1271        // Keep the owner receiver alive until finalizing is visible. Persist
1272        // the observed-generation marker before a successor can start.
1273        if let Err(error) = self.persistence().checkpoint_runtime_session(session).await {
1274            tracing::warn!(
1275                session_id = %session.id,
1276                %error,
1277                "failed to checkpoint direct SDK terminal SessionInbox state"
1278            );
1279        }
1280        if let Err(error) = registration.finish(executed_admitted_generation).await {
1281            tracing::error!(
1282                session_id = %session.id,
1283                %error,
1284                "direct SDK SessionInbox finalization failed"
1285            );
1286        }
1287
1288        result
1289    }
1290
1291    /// Access the shared storage backend.
1292    pub fn storage(&self) -> &Arc<dyn bamboo_agent_core::storage::Storage> {
1293        &self.runtime.storage
1294    }
1295
1296    /// Access the runtime persistence adapter for non-authoritative saves.
1297    pub fn persistence(&self) -> &Arc<dyn RuntimeSessionPersistence> {
1298        &self.runtime.persistence
1299    }
1300
1301    pub fn session_inbox(&self) -> Option<&Arc<dyn bamboo_domain::SessionInboxPort>> {
1302        self.runtime.session_inbox.as_ref()
1303    }
1304
1305    /// Execute the same durable SessionInbox boundary used by the agent loop
1306    /// before its first provider call. This compatibility method reports only
1307    /// the merge count; it cannot confirm successful ACK. Execution entry points
1308    /// must use [`Self::admit_session_inbox_at_safe_boundary_checked`] instead.
1309    pub async fn admit_session_inbox_at_safe_boundary(
1310        &self,
1311        session: &mut bamboo_agent_core::Session,
1312    ) -> usize {
1313        crate::runtime::runner::state_bridge::refresh_turn_boundary_with_inbox(
1314            session,
1315            Some(self.storage()),
1316            Some(self.persistence()),
1317            self.session_inbox(),
1318        )
1319        .await
1320        .merged
1321    }
1322
1323    /// Confirm the durable admission boundary before entering provider execution.
1324    /// An ACK error is unresolved even after receipt publication. Preserve the
1325    /// checkpoint and existing claim recovery, but reject this activation.
1326    pub async fn admit_session_inbox_at_safe_boundary_checked(
1327        &self,
1328        session: &mut bamboo_agent_core::Session,
1329    ) -> Result<usize, bamboo_agent_core::AgentError> {
1330        let refresh = crate::runtime::runner::state_bridge::refresh_turn_boundary_with_inbox(
1331            session,
1332            Some(self.storage()),
1333            Some(self.persistence()),
1334            self.session_inbox(),
1335        )
1336        .await;
1337        if let Some(error) = refresh.admission_error {
1338            return Err(bamboo_agent_core::AgentError::Tool(error));
1339        }
1340        Ok(refresh.merged)
1341    }
1342
1343    pub fn activation_router(
1344        &self,
1345    ) -> Option<&Arc<crate::session_activation::SessionActivationRouter>> {
1346        self.runtime.activation_router.as_ref()
1347    }
1348
1349    pub fn session_messenger(&self) -> Option<&Arc<crate::SessionMessenger>> {
1350        self.runtime.session_messenger.as_ref()
1351    }
1352
1353    /// Access the runtime's default tool executor (the root/full tool surface
1354    /// assembled at build time).
1355    ///
1356    /// Exposed so callers can compose additional one-off dispatches against the
1357    /// SAME executor the loop itself uses — e.g. re-executing a single
1358    /// previously-gated tool call after a permission approval — without forking
1359    /// or reaching into `AgentLoopConfig` (which stays unconstructible outside
1360    /// the engine). This is a read-only accessor alongside `storage()` /
1361    /// `persistence()`; it does not touch the sealed loop config.
1362    pub fn default_tools(&self) -> &Arc<dyn bamboo_agent_core::tools::ToolExecutor> {
1363        &self.runtime.default_tools
1364    }
1365}
1366
1367// ---------------------------------------------------------------------------
1368// AgentBuilder
1369// ---------------------------------------------------------------------------
1370
1371/// Builder for [`Agent`].
1372///
1373/// Delegates to [`AgentRuntimeBuilder`] internally.
1374pub struct AgentBuilder {
1375    inner: AgentRuntimeBuilder,
1376}
1377
1378impl AgentBuilder {
1379    pub fn new() -> Self {
1380        Self {
1381            inner: AgentRuntimeBuilder::new(),
1382        }
1383    }
1384
1385    pub fn storage(mut self, v: Arc<dyn bamboo_agent_core::storage::Storage>) -> Self {
1386        self.inner = self.inner.storage(v);
1387        self
1388    }
1389
1390    pub fn persistence(mut self, v: Arc<dyn RuntimeSessionPersistence>) -> Self {
1391        self.inner = self.inner.persistence(v);
1392        self
1393    }
1394
1395    pub fn session_inbox(mut self, v: Arc<dyn bamboo_domain::SessionInboxPort>) -> Self {
1396        self.inner = self.inner.session_inbox(v);
1397        self
1398    }
1399
1400    pub fn activation_router(
1401        mut self,
1402        v: Arc<crate::session_activation::SessionActivationRouter>,
1403    ) -> Self {
1404        self.inner = self.inner.activation_router(v);
1405        self
1406    }
1407
1408    pub fn session_messenger(mut self, v: Arc<crate::SessionMessenger>) -> Self {
1409        self.inner = self.inner.session_messenger(v);
1410        self
1411    }
1412
1413    pub fn attachment_reader(
1414        mut self,
1415        v: Arc<dyn bamboo_agent_core::storage::AttachmentReader>,
1416    ) -> Self {
1417        self.inner = self.inner.attachment_reader(v);
1418        self
1419    }
1420
1421    pub fn skill_manager(mut self, v: Arc<bamboo_skills::SkillManager>) -> Self {
1422        self.inner = self.inner.skill_manager(v);
1423        self
1424    }
1425
1426    pub fn project_context_resolver(
1427        mut self,
1428        v: Arc<crate::project_context::ProjectContextResolver>,
1429    ) -> Self {
1430        self.inner = self.inner.project_context_resolver(v);
1431        self
1432    }
1433
1434    pub fn metrics_collector(mut self, v: bamboo_metrics::MetricsCollector) -> Self {
1435        self.inner = self.inner.metrics_collector(v);
1436        self
1437    }
1438
1439    pub fn config(mut self, v: Arc<tokio::sync::RwLock<bamboo_llm::Config>>) -> Self {
1440        self.inner = self.inner.config(v);
1441        self
1442    }
1443
1444    /// Resolve model limits from a host-owned last-known-good section on each
1445    /// round instead of reopening the instance's configuration file.
1446    pub fn model_limits_section(
1447        mut self,
1448        section: Arc<bamboo_config::LiveSection<bamboo_config::ModelLimitsSection>>,
1449    ) -> Self {
1450        self.inner = self.inner.model_limits_section(section);
1451        self
1452    }
1453
1454    pub fn permission_config(mut self, v: Arc<bamboo_tools::permission::PermissionConfig>) -> Self {
1455        self.inner = self.inner.permission_config(v);
1456        self
1457    }
1458
1459    pub fn permission_mode(mut self, v: bamboo_domain::PermissionMode) -> Self {
1460        self.inner = self.inner.permission_mode(v);
1461        self
1462    }
1463
1464    pub fn provider(mut self, v: Arc<dyn bamboo_llm::LLMProvider>) -> Self {
1465        self.inner = self.inner.provider(v);
1466        self
1467    }
1468
1469    pub fn memory_store(mut self, v: bamboo_memory::memory_store::MemoryStore) -> Self {
1470        self.inner = self.inner.memory_store(v);
1471        self
1472    }
1473
1474    pub fn default_tools(mut self, v: Arc<dyn bamboo_agent_core::tools::ToolExecutor>) -> Self {
1475        self.inner = self.inner.default_tools(v);
1476        self
1477    }
1478
1479    /// Install an immutable lifecycle-hook registry for this agent runtime.
1480    pub fn hook_runner(mut self, v: Arc<crate::runtime::HookRunner>) -> Self {
1481        self.inner = self.inner.hook_runner(v);
1482        self
1483    }
1484
1485    pub fn build(self) -> Result<Agent, &'static str> {
1486        let runtime = self.inner.build()?;
1487        Ok(Agent {
1488            runtime: Arc::new(runtime),
1489        })
1490    }
1491}
1492
1493impl Default for AgentBuilder {
1494    fn default() -> Self {
1495        Self::new()
1496    }
1497}
1498
1499#[cfg(test)]
1500mod auxiliary_factory_isolation_tests {
1501    use super::*;
1502    use bamboo_agent_core::storage::Storage;
1503    use bamboo_agent_core::{Message, ToolSchema};
1504    use bamboo_llm::{LLMError, LLMProvider, LLMStream};
1505
1506    struct UnusedProvider;
1507
1508    #[async_trait::async_trait]
1509    impl LLMProvider for UnusedProvider {
1510        async fn chat_stream(
1511            &self,
1512            _messages: &[Message],
1513            _tools: &[ToolSchema],
1514            _max_output_tokens: Option<u32>,
1515            _model: &str,
1516        ) -> Result<LLMStream, LLMError> {
1517            Err(LLMError::Api("provider must not be called".into()))
1518        }
1519    }
1520
1521    #[tokio::test]
1522    async fn factory_clone_discards_only_its_inherited_route_overrides() {
1523        let dir = tempfile::tempdir().unwrap();
1524        let store = Arc::new(
1525            bamboo_storage::SessionStoreV2::new(dir.path().join("sessions"))
1526                .await
1527                .unwrap(),
1528        );
1529        let storage: Arc<dyn Storage> = store.clone();
1530        let persistence: Arc<dyn RuntimeSessionPersistence> =
1531            Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
1532        let metrics = bamboo_metrics::MetricsCollector::spawn(
1533            Arc::new(bamboo_metrics::SqliteMetricsStorage::new(
1534                dir.path().join("metrics.db"),
1535            )),
1536            7,
1537        );
1538        let ordinary = Agent::builder()
1539            .storage(storage)
1540            .persistence(persistence)
1541            .attachment_reader(store)
1542            .skill_manager(Arc::new(bamboo_skills::SkillManager::new()))
1543            .metrics_collector(metrics)
1544            .config(Arc::new(tokio::sync::RwLock::new(
1545                bamboo_llm::Config::default(),
1546            )))
1547            .provider(Arc::new(UnusedProvider))
1548            .default_tools(Arc::new(bamboo_tools::BuiltinToolExecutor::new()))
1549            .build()
1550            .unwrap();
1551        let background: crate::runtime::config::BackgroundModelResolver = Arc::new(|_| None);
1552        let resume: crate::runtime::config::ResumeAuxiliaryResolver = Arc::new(|| (Ok(None), None));
1553        let bound = ordinary
1554            .with_background_model_resolver(background.clone())
1555            .with_resume_auxiliary_resolver(resume.clone());
1556        let sibling = bound.clone();
1557        let schedule = bound.with_auxiliary_model_resolver_factory(Arc::new(|key| {
1558            let key = key.to_owned();
1559            Arc::new(move || crate::AuxiliaryModelConfig {
1560                fast_model_name: Some(key.clone()),
1561                ..Default::default()
1562            })
1563        }));
1564
1565        assert!(ordinary.runtime.auxiliary_model_resolver_factory.is_none());
1566        assert!(ordinary.runtime.background_model_resolver.is_none());
1567        assert!(ordinary.runtime.resume_auxiliary_resolver.is_none());
1568        for agent in [&bound, &sibling] {
1569            assert!(agent.runtime.auxiliary_model_resolver_factory.is_none());
1570            assert!(Arc::ptr_eq(
1571                agent.runtime.background_model_resolver.as_ref().unwrap(),
1572                &background,
1573            ));
1574            assert!(Arc::ptr_eq(
1575                agent.runtime.resume_auxiliary_resolver.as_ref().unwrap(),
1576                &resume,
1577            ));
1578        }
1579        assert!(schedule.runtime.background_model_resolver.is_none());
1580        assert!(schedule.runtime.resume_auxiliary_resolver.is_none());
1581        let factory = schedule
1582            .runtime
1583            .auxiliary_model_resolver_factory
1584            .as_ref()
1585            .unwrap();
1586        assert_eq!(
1587            factory("run-key")().fast_model_name.as_deref(),
1588            Some("run-key")
1589        );
1590    }
1591}
1592
1593#[cfg(test)]
1594#[path = "child_resume_gold_policy_tests.rs"]
1595mod child_resume_gold_policy_tests;