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 Agent {
41    /// Wrap an existing [`AgentRuntime`] in an `Agent`.
42    pub fn from_runtime(runtime: Arc<AgentRuntime>) -> Self {
43        Agent { runtime }
44    }
45
46    /// Return a new builder.
47    pub fn builder() -> AgentBuilder {
48        AgentBuilder::new()
49    }
50
51    /// Execute the agent loop with the given request.
52    pub async fn execute(
53        &self,
54        session: &mut Session,
55        req: ExecuteRequest,
56    ) -> crate::runtime::runner::Result<()> {
57        self.runtime.execute(session, req).await
58    }
59
60    /// Execute a caller-owned session under a complete logical-session
61    /// activation lifecycle.
62    ///
63    /// Server/child entry points already own an external runner reservation and
64    /// therefore use [`execute`](Self::execute) plus their existing terminal
65    /// handshake. Direct SDK callers have no runner registry, so this wrapper
66    /// registers the current run before entering the provider loop, marks it
67    /// finalizing immediately after return, migrates any terminal-window legacy
68    /// ingress, and lets the router reserve at most one successor for work the
69    /// completed reasoning turn did not admit.
70    pub async fn execute_direct(
71        &self,
72        session: &mut Session,
73        req: ExecuteRequest,
74    ) -> crate::runtime::runner::Result<()> {
75        let lease = self.begin_direct_execution(&session.id).await?;
76        self.execute_direct_registered(session, req, lease).await
77    }
78
79    /// Acquire direct logical-session ownership before an SDK facade performs
80    /// pre-execution work such as replaying an approved mutating tool.
81    pub async fn begin_direct_execution(
82        &self,
83        target_session_id: &str,
84    ) -> crate::runtime::runner::Result<DirectExecutionLease> {
85        let Some(router) = self.activation_router().cloned() else {
86            return Ok(DirectExecutionLease {
87                target_session_id: target_session_id.to_string(),
88                router: None,
89                registration: None,
90            });
91        };
92        let run_id = format!("sdk-direct-{}", uuid::Uuid::new_v4());
93        let registration = router
94            .register_run(target_session_id, &run_id)
95            .await
96            .map_err(|error| bamboo_agent_core::AgentError::LLM(error.to_string()))?;
97        Ok(DirectExecutionLease {
98            target_session_id: target_session_id.to_string(),
99            router: Some(router),
100            registration: Some(registration),
101        })
102    }
103
104    /// Execute and finalize a direct run whose ownership was acquired by
105    /// [`begin_direct_execution`](Self::begin_direct_execution).
106    pub async fn execute_direct_registered(
107        &self,
108        session: &mut Session,
109        req: ExecuteRequest,
110        mut lease: DirectExecutionLease,
111    ) -> crate::runtime::runner::Result<()> {
112        if lease.target_session_id != session.id {
113            return Err(bamboo_agent_core::AgentError::LLM(format!(
114                "direct execution lease target {} does not match session {}",
115                lease.target_session_id, session.id
116            )));
117        }
118        let Some(router) = lease.router.take() else {
119            return self.execute(session, req).await;
120        };
121        let mut registration = lease.registration.take().ok_or_else(|| {
122            bamboo_agent_core::AgentError::LLM(
123                "direct execution lease is missing its router registration".to_string(),
124            )
125        })?;
126        let result = self.execute(session, req).await;
127
128        // Freeze what this provider execution actually consumed. Compatibility
129        // migration and concurrent deliveries below must remain newer work.
130        let executed_admitted_generation = session
131            .session_inbox_admission()
132            .map_or(0, |state| state.last_admitted_sequence);
133        registration.begin_finalization().await;
134
135        let legacy_migration = crate::runtime::runner::state_bridge::migrate_legacy_pending_only(
136            session,
137            Some(self.storage()),
138            Some(self.persistence()),
139            self.session_inbox(),
140        )
141        .await;
142        if let Some(generation) = legacy_migration.highest_generation {
143            session.session_inbox_admission_mut().observe(generation);
144        }
145        let pending_generation = session
146            .session_inbox_admission()
147            .and_then(|state| state.pending_activation_generation());
148        if let Some(generation) = pending_generation {
149            let activation_ready = if let Some(inbox) = self.session_inbox() {
150                match inbox
151                    .mark_activation_eligible(
152                        &session.id,
153                        generation,
154                        bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
155                    )
156                    .await
157                {
158                    Ok(()) => true,
159                    Err(error) => {
160                        tracing::error!(
161                            session_id = %session.id,
162                            %error,
163                            "failed to persist direct SDK SessionInbox activation watermark"
164                        );
165                        false
166                    }
167                }
168            } else {
169                false
170            };
171            if activation_ready {
172                if let Err(error) = bamboo_domain::SessionActivationPort::request_activation(
173                    router.as_ref(),
174                    &session.id,
175                    generation,
176                )
177                .await
178                {
179                    tracing::error!(
180                        session_id = %session.id,
181                        %error,
182                        "failed to hand direct SDK SessionInbox generation to activation router"
183                    );
184                }
185            }
186        }
187
188        // Keep the owner receiver alive until finalizing is visible. Persist
189        // the observed-generation marker before a successor can start.
190        if let Err(error) = self.persistence().checkpoint_runtime_session(session).await {
191            tracing::warn!(
192                session_id = %session.id,
193                %error,
194                "failed to checkpoint direct SDK terminal SessionInbox state"
195            );
196        }
197        if let Err(error) = registration.finish(executed_admitted_generation).await {
198            tracing::error!(
199                session_id = %session.id,
200                %error,
201                "direct SDK SessionInbox finalization failed"
202            );
203        }
204
205        result
206    }
207
208    /// Access the shared storage backend.
209    pub fn storage(&self) -> &Arc<dyn bamboo_agent_core::storage::Storage> {
210        &self.runtime.storage
211    }
212
213    /// Access the runtime persistence adapter for non-authoritative saves.
214    pub fn persistence(&self) -> &Arc<dyn RuntimeSessionPersistence> {
215        &self.runtime.persistence
216    }
217
218    pub fn session_inbox(&self) -> Option<&Arc<dyn bamboo_domain::SessionInboxPort>> {
219        self.runtime.session_inbox.as_ref()
220    }
221
222    /// Execute the same durable SessionInbox boundary used by the agent loop
223    /// before its first provider call. Actor workers use this after embedding
224    /// initial RunSpec deliveries, so those messages cannot race the first
225    /// reasoning context.
226    pub async fn admit_session_inbox_at_safe_boundary(
227        &self,
228        session: &mut bamboo_agent_core::Session,
229    ) -> usize {
230        crate::runtime::runner::state_bridge::refresh_turn_boundary_with_inbox(
231            session,
232            Some(self.storage()),
233            Some(self.persistence()),
234            self.session_inbox(),
235        )
236        .await
237        .merged
238    }
239
240    pub fn activation_router(
241        &self,
242    ) -> Option<&Arc<crate::session_activation::SessionActivationRouter>> {
243        self.runtime.activation_router.as_ref()
244    }
245
246    pub fn session_messenger(&self) -> Option<&Arc<crate::SessionMessenger>> {
247        self.runtime.session_messenger.as_ref()
248    }
249
250    /// Access the runtime's default tool executor (the root/full tool surface
251    /// assembled at build time).
252    ///
253    /// Exposed so callers can compose additional one-off dispatches against the
254    /// SAME executor the loop itself uses — e.g. re-executing a single
255    /// previously-gated tool call after a permission approval — without forking
256    /// or reaching into `AgentLoopConfig` (which stays unconstructible outside
257    /// the engine). This is a read-only accessor alongside `storage()` /
258    /// `persistence()`; it does not touch the sealed loop config.
259    pub fn default_tools(&self) -> &Arc<dyn bamboo_agent_core::tools::ToolExecutor> {
260        &self.runtime.default_tools
261    }
262}
263
264// ---------------------------------------------------------------------------
265// AgentBuilder
266// ---------------------------------------------------------------------------
267
268/// Builder for [`Agent`].
269///
270/// Delegates to [`AgentRuntimeBuilder`] internally.
271pub struct AgentBuilder {
272    inner: AgentRuntimeBuilder,
273}
274
275impl AgentBuilder {
276    pub fn new() -> Self {
277        Self {
278            inner: AgentRuntimeBuilder::new(),
279        }
280    }
281
282    pub fn storage(mut self, v: Arc<dyn bamboo_agent_core::storage::Storage>) -> Self {
283        self.inner = self.inner.storage(v);
284        self
285    }
286
287    pub fn persistence(mut self, v: Arc<dyn RuntimeSessionPersistence>) -> Self {
288        self.inner = self.inner.persistence(v);
289        self
290    }
291
292    pub fn session_inbox(mut self, v: Arc<dyn bamboo_domain::SessionInboxPort>) -> Self {
293        self.inner = self.inner.session_inbox(v);
294        self
295    }
296
297    pub fn activation_router(
298        mut self,
299        v: Arc<crate::session_activation::SessionActivationRouter>,
300    ) -> Self {
301        self.inner = self.inner.activation_router(v);
302        self
303    }
304
305    pub fn session_messenger(mut self, v: Arc<crate::SessionMessenger>) -> Self {
306        self.inner = self.inner.session_messenger(v);
307        self
308    }
309
310    pub fn attachment_reader(
311        mut self,
312        v: Arc<dyn bamboo_agent_core::storage::AttachmentReader>,
313    ) -> Self {
314        self.inner = self.inner.attachment_reader(v);
315        self
316    }
317
318    pub fn skill_manager(mut self, v: Arc<bamboo_skills::SkillManager>) -> Self {
319        self.inner = self.inner.skill_manager(v);
320        self
321    }
322
323    pub fn project_context_resolver(
324        mut self,
325        v: Arc<crate::project_context::ProjectContextResolver>,
326    ) -> Self {
327        self.inner = self.inner.project_context_resolver(v);
328        self
329    }
330
331    pub fn metrics_collector(mut self, v: bamboo_metrics::MetricsCollector) -> Self {
332        self.inner = self.inner.metrics_collector(v);
333        self
334    }
335
336    pub fn config(mut self, v: Arc<tokio::sync::RwLock<bamboo_llm::Config>>) -> Self {
337        self.inner = self.inner.config(v);
338        self
339    }
340
341    pub fn provider(mut self, v: Arc<dyn bamboo_llm::LLMProvider>) -> Self {
342        self.inner = self.inner.provider(v);
343        self
344    }
345
346    pub fn default_tools(mut self, v: Arc<dyn bamboo_agent_core::tools::ToolExecutor>) -> Self {
347        self.inner = self.inner.default_tools(v);
348        self
349    }
350
351    /// Install an immutable lifecycle-hook registry for this agent runtime.
352    pub fn hook_runner(mut self, v: Arc<crate::runtime::HookRunner>) -> Self {
353        self.inner = self.inner.hook_runner(v);
354        self
355    }
356
357    pub fn build(self) -> Result<Agent, &'static str> {
358        let runtime = self.inner.build()?;
359        Ok(Agent {
360            runtime: Arc::new(runtime),
361        })
362    }
363}
364
365impl Default for AgentBuilder {
366    fn default() -> Self {
367        Self::new()
368    }
369}