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