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// ---------------------------------------------------------------------------
14// Agent — stable public object
15// ---------------------------------------------------------------------------
16
17/// Stable public entry point for agent execution.
18///
19/// Wraps an [`AgentRuntime`] and provides:
20/// - [`Agent::execute()`] — run the agent loop on a session
21/// - [`Agent::storage()`] — access the shared storage backend
22///
23/// Clone is cheap (inner is `Arc`).
24#[derive(Clone)]
25pub struct Agent {
26    runtime: Arc<AgentRuntime>,
27}
28
29/// Opaque ownership lease for one direct logical-session execution.
30///
31/// SDK facades acquire this before any pre-execution side effect, then transfer
32/// it into [`Agent::execute_direct_registered`]. Dropping it early invokes the
33/// same abandoned-owner recovery as cancellation during provider execution.
34pub struct DirectExecutionLease {
35    target_session_id: String,
36    router: Option<Arc<crate::session_activation::SessionActivationRouter>>,
37    registration: Option<crate::session_activation::SessionRunRegistration>,
38}
39
40impl DirectExecutionLease {
41    /// Release ownership before returning a handled pre-execution stop to an
42    /// SDK caller, so its next resume does not race the asynchronous Drop path.
43    pub async fn abandon(mut self) {
44        if let Some(registration) = self.registration.take() {
45            registration.abandon().await;
46        }
47    }
48}
49
50impl Agent {
51    /// Wrap an existing [`AgentRuntime`] in an `Agent`.
52    pub fn from_runtime(runtime: Arc<AgentRuntime>) -> Self {
53        Agent { runtime }
54    }
55
56    /// One execution's immutable persistence capability. Shared tool/provider
57    /// resources remain on the existing runtime; default callers stay unbound.
58    #[doc(hidden)]
59    pub fn with_execution_persistence(
60        &self,
61        persistence: Arc<dyn RuntimeSessionPersistence>,
62    ) -> Self {
63        let mut runtime = (*self.runtime).clone();
64        runtime.persistence = persistence;
65        runtime.inherited_child_wait_captured = true;
66        Self::from_runtime(Arc::new(runtime))
67    }
68
69    /// Return a new builder.
70    pub fn builder() -> AgentBuilder {
71        AgentBuilder::new()
72    }
73
74    /// Execute the agent loop with the given request.
75    pub async fn execute(
76        &self,
77        session: &mut Session,
78        req: ExecuteRequest,
79    ) -> crate::runtime::runner::Result<()> {
80        self.runtime.execute(session, req).await
81    }
82
83    /// Execute a caller-owned session under a complete logical-session
84    /// activation lifecycle.
85    ///
86    /// Server/child entry points already own an external runner reservation and
87    /// therefore use [`execute`](Self::execute) plus their existing terminal
88    /// handshake. Direct SDK callers have no runner registry, so this wrapper
89    /// registers the current run before entering the provider loop, marks it
90    /// finalizing immediately after return, migrates any terminal-window legacy
91    /// ingress, and lets the router reserve at most one successor for work the
92    /// completed reasoning turn did not admit.
93    pub async fn execute_direct(
94        &self,
95        session: &mut Session,
96        req: ExecuteRequest,
97    ) -> crate::runtime::runner::Result<()> {
98        let lease = self.begin_direct_execution(&session.id).await?;
99        self.prepare_external_session_for_execution(session).await?;
100        self.execute_direct_registered(session, req, lease).await
101    }
102
103    /// Validate and publish Project/Workspace context for a caller-owned
104    /// session before any external pre-execution side effect.
105    ///
106    /// SDK facades that acquire a direct lease themselves call this before
107    /// approved-tool replay. [`execute_direct`](Self::execute_direct) also calls
108    /// it, so the lower-level escape hatch cannot bypass legacy migration,
109    /// Project validation, or runtime workspace publication.
110    pub async fn prepare_external_session_for_execution(
111        &self,
112        session: &mut Session,
113    ) -> crate::runtime::runner::Result<()> {
114        crate::session_app::execution_prep::prepare_external_session_for_execution(
115            session,
116            self.runtime.project_context_resolver.as_deref(),
117        )
118        .await
119    }
120
121    /// Resolve a proposed SDK Project assignment without publishing a runtime
122    /// workspace. The caller must persist the validated candidate before the
123    /// ordinary execution handoff publishes it or replays an approved tool.
124    pub async fn prepare_external_project_assignment_read_only(
125        &self,
126        session: &mut Session,
127    ) -> crate::runtime::runner::Result<()> {
128        let resolver = self
129            .runtime
130            .project_context_resolver
131            .as_deref()
132            .ok_or_else(|| {
133                bamboo_agent_core::AgentError::ProjectContext(
134                    "Project assignment requires a ProjectContextResolver".to_string(),
135                )
136            })?;
137        resolver
138            .refresh_session_prompt_read_only(session)
139            .await
140            .map(|_| ())
141            .map_err(|error| bamboo_agent_core::AgentError::ProjectContext(error.to_string()))
142    }
143
144    /// Acquire direct logical-session ownership before an SDK facade performs
145    /// pre-execution work such as replaying an approved mutating tool.
146    pub async fn begin_direct_execution(
147        &self,
148        target_session_id: &str,
149    ) -> crate::runtime::runner::Result<DirectExecutionLease> {
150        let Some(router) = self.activation_router().cloned() else {
151            return Ok(DirectExecutionLease {
152                target_session_id: target_session_id.to_string(),
153                router: None,
154                registration: None,
155            });
156        };
157        let run_id = format!("sdk-direct-{}", uuid::Uuid::new_v4());
158        let registration = router
159            .register_run(target_session_id, &run_id)
160            .await
161            .map_err(|error| bamboo_agent_core::AgentError::LLM(error.to_string()))?;
162        Ok(DirectExecutionLease {
163            target_session_id: target_session_id.to_string(),
164            router: Some(router),
165            registration: Some(registration),
166        })
167    }
168
169    /// Execute and finalize a direct run whose ownership was acquired by
170    /// [`begin_direct_execution`](Self::begin_direct_execution).
171    pub async fn execute_direct_registered(
172        &self,
173        session: &mut Session,
174        req: ExecuteRequest,
175        lease: DirectExecutionLease,
176    ) -> crate::runtime::runner::Result<()> {
177        let inherited = self.persistence().inherited_child_wait().or_else(|| {
178            (!self.runtime.inherited_child_wait_captured)
179                .then(|| bamboo_domain::InheritedChildWait::capture(session))
180                .flatten()
181        });
182        let agent = if let Some(inherited) = inherited {
183            inherited
184                .validate_session(session)
185                .map_err(|error| bamboo_agent_core::AgentError::LLM(error.to_string()))?;
186            if self.persistence().inherited_child_wait().is_some() {
187                self.clone()
188            } else {
189                self.with_execution_persistence(
190                    self.persistence()
191                        .bind_inherited_child_wait(inherited)
192                        .map_err(|error| bamboo_agent_core::AgentError::LLM(error.to_string()))?,
193                )
194            }
195        } else {
196            self.with_execution_persistence(self.persistence().clone())
197        };
198        agent
199            .execute_direct_registered_bound(session, req, lease)
200            .await
201    }
202
203    async fn execute_direct_registered_bound(
204        &self,
205        session: &mut Session,
206        req: ExecuteRequest,
207        mut lease: DirectExecutionLease,
208    ) -> crate::runtime::runner::Result<()> {
209        if lease.target_session_id != session.id {
210            return Err(bamboo_agent_core::AgentError::LLM(format!(
211                "direct execution lease target {} does not match session {}",
212                lease.target_session_id, session.id
213            )));
214        }
215        let Some(router) = lease.router.take() else {
216            return self.execute(session, req).await;
217        };
218        let mut registration = lease.registration.take().ok_or_else(|| {
219            bamboo_agent_core::AgentError::LLM(
220                "direct execution lease is missing its router registration".to_string(),
221            )
222        })?;
223        let result = self.execute(session, req).await;
224
225        // Freeze what this provider execution actually consumed. Compatibility
226        // migration and concurrent deliveries below must remain newer work.
227        let executed_admitted_generation = session
228            .session_inbox_admission()
229            .map_or(0, |state| state.last_admitted_sequence);
230        registration.begin_finalization().await;
231
232        let legacy_migration = crate::runtime::runner::state_bridge::migrate_legacy_pending_only(
233            session,
234            Some(self.storage()),
235            Some(self.persistence()),
236            self.session_inbox(),
237        )
238        .await;
239        if let Some(generation) = legacy_migration.highest_generation {
240            session.session_inbox_admission_mut().observe(generation);
241        }
242        let pending_generation = session
243            .session_inbox_admission()
244            .and_then(|state| state.pending_activation_generation());
245        if let Some(generation) = pending_generation {
246            let activation_ready = if let Some(inbox) = self.session_inbox() {
247                match inbox
248                    .mark_activation_eligible(
249                        &session.id,
250                        generation,
251                        bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
252                    )
253                    .await
254                {
255                    Ok(()) => true,
256                    Err(error) => {
257                        tracing::error!(
258                            session_id = %session.id,
259                            %error,
260                            "failed to persist direct SDK SessionInbox activation watermark"
261                        );
262                        false
263                    }
264                }
265            } else {
266                false
267            };
268            if activation_ready {
269                if let Err(error) = bamboo_domain::SessionActivationPort::request_activation(
270                    router.as_ref(),
271                    &session.id,
272                    generation,
273                )
274                .await
275                {
276                    tracing::error!(
277                        session_id = %session.id,
278                        %error,
279                        "failed to hand direct SDK SessionInbox generation to activation router"
280                    );
281                }
282            }
283        }
284
285        // Keep the owner receiver alive until finalizing is visible. Persist
286        // the observed-generation marker before a successor can start.
287        if let Err(error) = self.persistence().checkpoint_runtime_session(session).await {
288            tracing::warn!(
289                session_id = %session.id,
290                %error,
291                "failed to checkpoint direct SDK terminal SessionInbox state"
292            );
293        }
294        if let Err(error) = registration.finish(executed_admitted_generation).await {
295            tracing::error!(
296                session_id = %session.id,
297                %error,
298                "direct SDK SessionInbox finalization failed"
299            );
300        }
301
302        result
303    }
304
305    /// Access the shared storage backend.
306    pub fn storage(&self) -> &Arc<dyn bamboo_agent_core::storage::Storage> {
307        &self.runtime.storage
308    }
309
310    /// Access the runtime persistence adapter for non-authoritative saves.
311    pub fn persistence(&self) -> &Arc<dyn RuntimeSessionPersistence> {
312        &self.runtime.persistence
313    }
314
315    pub fn session_inbox(&self) -> Option<&Arc<dyn bamboo_domain::SessionInboxPort>> {
316        self.runtime.session_inbox.as_ref()
317    }
318
319    /// Execute the same durable SessionInbox boundary used by the agent loop
320    /// before its first provider call. This compatibility method reports only
321    /// the merge count; it cannot confirm successful ACK. Execution entry points
322    /// must use [`Self::admit_session_inbox_at_safe_boundary_checked`] instead.
323    pub async fn admit_session_inbox_at_safe_boundary(
324        &self,
325        session: &mut bamboo_agent_core::Session,
326    ) -> usize {
327        crate::runtime::runner::state_bridge::refresh_turn_boundary_with_inbox(
328            session,
329            Some(self.storage()),
330            Some(self.persistence()),
331            self.session_inbox(),
332        )
333        .await
334        .merged
335    }
336
337    /// Confirm the durable admission boundary before entering provider execution.
338    /// An ACK error is unresolved even after receipt publication. Preserve the
339    /// checkpoint and existing claim recovery, but reject this activation.
340    pub async fn admit_session_inbox_at_safe_boundary_checked(
341        &self,
342        session: &mut bamboo_agent_core::Session,
343    ) -> Result<usize, bamboo_agent_core::AgentError> {
344        let refresh = crate::runtime::runner::state_bridge::refresh_turn_boundary_with_inbox(
345            session,
346            Some(self.storage()),
347            Some(self.persistence()),
348            self.session_inbox(),
349        )
350        .await;
351        if let Some(error) = refresh.admission_error {
352            return Err(bamboo_agent_core::AgentError::Tool(error));
353        }
354        Ok(refresh.merged)
355    }
356
357    pub fn activation_router(
358        &self,
359    ) -> Option<&Arc<crate::session_activation::SessionActivationRouter>> {
360        self.runtime.activation_router.as_ref()
361    }
362
363    pub fn session_messenger(&self) -> Option<&Arc<crate::SessionMessenger>> {
364        self.runtime.session_messenger.as_ref()
365    }
366
367    /// Access the runtime's default tool executor (the root/full tool surface
368    /// assembled at build time).
369    ///
370    /// Exposed so callers can compose additional one-off dispatches against the
371    /// SAME executor the loop itself uses — e.g. re-executing a single
372    /// previously-gated tool call after a permission approval — without forking
373    /// or reaching into `AgentLoopConfig` (which stays unconstructible outside
374    /// the engine). This is a read-only accessor alongside `storage()` /
375    /// `persistence()`; it does not touch the sealed loop config.
376    pub fn default_tools(&self) -> &Arc<dyn bamboo_agent_core::tools::ToolExecutor> {
377        &self.runtime.default_tools
378    }
379}
380
381// ---------------------------------------------------------------------------
382// AgentBuilder
383// ---------------------------------------------------------------------------
384
385/// Builder for [`Agent`].
386///
387/// Delegates to [`AgentRuntimeBuilder`] internally.
388pub struct AgentBuilder {
389    inner: AgentRuntimeBuilder,
390}
391
392impl AgentBuilder {
393    pub fn new() -> Self {
394        Self {
395            inner: AgentRuntimeBuilder::new(),
396        }
397    }
398
399    pub fn storage(mut self, v: Arc<dyn bamboo_agent_core::storage::Storage>) -> Self {
400        self.inner = self.inner.storage(v);
401        self
402    }
403
404    pub fn persistence(mut self, v: Arc<dyn RuntimeSessionPersistence>) -> Self {
405        self.inner = self.inner.persistence(v);
406        self
407    }
408
409    pub fn session_inbox(mut self, v: Arc<dyn bamboo_domain::SessionInboxPort>) -> Self {
410        self.inner = self.inner.session_inbox(v);
411        self
412    }
413
414    pub fn activation_router(
415        mut self,
416        v: Arc<crate::session_activation::SessionActivationRouter>,
417    ) -> Self {
418        self.inner = self.inner.activation_router(v);
419        self
420    }
421
422    pub fn session_messenger(mut self, v: Arc<crate::SessionMessenger>) -> Self {
423        self.inner = self.inner.session_messenger(v);
424        self
425    }
426
427    pub fn attachment_reader(
428        mut self,
429        v: Arc<dyn bamboo_agent_core::storage::AttachmentReader>,
430    ) -> Self {
431        self.inner = self.inner.attachment_reader(v);
432        self
433    }
434
435    pub fn skill_manager(mut self, v: Arc<bamboo_skills::SkillManager>) -> Self {
436        self.inner = self.inner.skill_manager(v);
437        self
438    }
439
440    pub fn project_context_resolver(
441        mut self,
442        v: Arc<crate::project_context::ProjectContextResolver>,
443    ) -> Self {
444        self.inner = self.inner.project_context_resolver(v);
445        self
446    }
447
448    pub fn metrics_collector(mut self, v: bamboo_metrics::MetricsCollector) -> Self {
449        self.inner = self.inner.metrics_collector(v);
450        self
451    }
452
453    pub fn config(mut self, v: Arc<tokio::sync::RwLock<bamboo_llm::Config>>) -> Self {
454        self.inner = self.inner.config(v);
455        self
456    }
457
458    pub fn permission_config(mut self, v: Arc<bamboo_tools::permission::PermissionConfig>) -> Self {
459        self.inner = self.inner.permission_config(v);
460        self
461    }
462
463    pub fn permission_mode(mut self, v: bamboo_domain::PermissionMode) -> Self {
464        self.inner = self.inner.permission_mode(v);
465        self
466    }
467
468    pub fn provider(mut self, v: Arc<dyn bamboo_llm::LLMProvider>) -> Self {
469        self.inner = self.inner.provider(v);
470        self
471    }
472
473    pub fn memory_store(mut self, v: bamboo_memory::memory_store::MemoryStore) -> Self {
474        self.inner = self.inner.memory_store(v);
475        self
476    }
477
478    pub fn default_tools(mut self, v: Arc<dyn bamboo_agent_core::tools::ToolExecutor>) -> Self {
479        self.inner = self.inner.default_tools(v);
480        self
481    }
482
483    /// Install an immutable lifecycle-hook registry for this agent runtime.
484    pub fn hook_runner(mut self, v: Arc<crate::runtime::HookRunner>) -> Self {
485        self.inner = self.inner.hook_runner(v);
486        self
487    }
488
489    pub fn build(self) -> Result<Agent, &'static str> {
490        let runtime = self.inner.build()?;
491        Ok(Agent {
492            runtime: Arc::new(runtime),
493        })
494    }
495}
496
497impl Default for AgentBuilder {
498    fn default() -> Self {
499        Self::new()
500    }
501}