Skip to main content

bob_runtime/
typed_builder.rs

1//! # Type-State Runtime Builder
2//!
3//! A compile-time safe builder for [`AgentRuntime`] that enforces required
4//! fields (LLM port and default model) at the type level.
5//!
6//! ## Usage
7//!
8//! ```rust,ignore
9//! use bob_runtime::typed_builder::TypedRuntimeBuilder;
10//! use std::sync::Arc;
11//!
12//! let runtime = TypedRuntimeBuilder::new()
13//!     .with_llm(llm)              // transitions to HasLlm
14//!     .with_default_model("gpt")  // transitions to Ready
15//!     .with_store(store)          // optional
16//!     .with_events(events)        // optional
17//!     .build();                   // only available on Ready
18//! ```
19
20use std::{marker::PhantomData, sync::Arc};
21
22use bob_core::{
23    journal::ToolJournalPort,
24    ports::{
25        ApprovalPort, ArtifactStorePort, ContextCompactorPort, CostMeterPort, EventSink, LlmPort,
26        SessionStore, ToolPolicyPort, ToolPort, TurnCheckpointStorePort,
27    },
28    types::TurnPolicy,
29};
30
31use crate::{AgentError, AgentRuntime, DefaultAgentRuntime, DispatchMode, tooling::ToolLayer};
32
33// ── Type-State Markers ────────────────────────────────────────────────
34
35/// Initial state — no required fields set.
36#[derive(Debug, Clone, Copy, Default)]
37pub struct Empty;
38
39/// LLM port is set, model is not.
40#[derive(Debug, Clone, Copy, Default)]
41pub struct HasLlm;
42
43/// Both LLM port and default model are set — ready to build.
44#[derive(Debug, Clone, Copy, Default)]
45pub struct Ready;
46
47// ── Type-State Builder ────────────────────────────────────────────────
48
49/// Type-state builder that enforces required fields at compile time.
50///
51/// `S` tracks which required fields have been set:
52/// - [`Empty`]: No required fields set
53/// - [`HasLlm`]: LLM port set, default model not set
54/// - [`Ready`]: All required fields set, `build()` available
55#[derive(Default)]
56pub struct TypedRuntimeBuilder<S = Empty> {
57    llm: Option<Arc<dyn LlmPort>>,
58    tools: Option<Arc<dyn ToolPort>>,
59    store: Option<Arc<dyn SessionStore>>,
60    events: Option<Arc<dyn EventSink>>,
61    default_model: Option<String>,
62    policy: TurnPolicy,
63    tool_layers: Vec<Arc<dyn ToolLayer>>,
64    tool_policy: Option<Arc<dyn ToolPolicyPort>>,
65    approval: Option<Arc<dyn ApprovalPort>>,
66    dispatch_mode: DispatchMode,
67    checkpoint_store: Option<Arc<dyn TurnCheckpointStorePort>>,
68    artifact_store: Option<Arc<dyn ArtifactStorePort>>,
69    cost_meter: Option<Arc<dyn CostMeterPort>>,
70    context_compactor: Option<Arc<dyn ContextCompactorPort>>,
71    journal: Option<Arc<dyn ToolJournalPort>>,
72    _state: PhantomData<S>,
73}
74
75impl<S> std::fmt::Debug for TypedRuntimeBuilder<S> {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.debug_struct("TypedRuntimeBuilder")
78            .field("has_llm", &self.llm.is_some())
79            .field("has_default_model", &self.default_model.is_some())
80            .finish_non_exhaustive()
81    }
82}
83
84impl TypedRuntimeBuilder<Empty> {
85    /// Create a new empty builder.
86    #[must_use]
87    pub fn new() -> Self {
88        Self {
89            llm: None,
90            tools: None,
91            store: None,
92            events: None,
93            default_model: None,
94            policy: TurnPolicy::default(),
95            tool_layers: Vec::new(),
96            tool_policy: None,
97            approval: None,
98            dispatch_mode: DispatchMode::default(),
99            checkpoint_store: None,
100            artifact_store: None,
101            cost_meter: None,
102            context_compactor: None,
103            journal: None,
104            _state: PhantomData,
105        }
106    }
107
108    /// Set the LLM port. Transitions to [`HasLlm`] state.
109    #[must_use]
110    pub fn with_llm(mut self, llm: Arc<dyn LlmPort>) -> TypedRuntimeBuilder<HasLlm> {
111        self.llm = Some(llm);
112        TypedRuntimeBuilder {
113            llm: self.llm,
114            tools: self.tools,
115            store: self.store,
116            events: self.events,
117            default_model: self.default_model,
118            policy: self.policy,
119            tool_layers: self.tool_layers,
120            tool_policy: self.tool_policy,
121            approval: self.approval,
122            dispatch_mode: self.dispatch_mode,
123            checkpoint_store: self.checkpoint_store,
124            artifact_store: self.artifact_store,
125            cost_meter: self.cost_meter,
126            context_compactor: self.context_compactor,
127            journal: self.journal,
128            _state: PhantomData,
129        }
130    }
131}
132
133impl TypedRuntimeBuilder<HasLlm> {
134    /// Set the default model. Transitions to [`Ready`] state.
135    #[must_use]
136    pub fn with_default_model(mut self, model: impl Into<String>) -> TypedRuntimeBuilder<Ready> {
137        self.default_model = Some(model.into());
138        TypedRuntimeBuilder {
139            llm: self.llm,
140            tools: self.tools,
141            store: self.store,
142            events: self.events,
143            default_model: self.default_model,
144            policy: self.policy,
145            tool_layers: self.tool_layers,
146            tool_policy: self.tool_policy,
147            approval: self.approval,
148            dispatch_mode: self.dispatch_mode,
149            checkpoint_store: self.checkpoint_store,
150            artifact_store: self.artifact_store,
151            cost_meter: self.cost_meter,
152            context_compactor: self.context_compactor,
153            journal: self.journal,
154            _state: PhantomData,
155        }
156    }
157
158    /// Set the session store. (Stays in `HasLlm` state.)
159    #[must_use]
160    pub fn with_store(mut self, store: Arc<dyn SessionStore>) -> Self {
161        self.store = Some(store);
162        self
163    }
164
165    /// Set the event sink. (Stays in `HasLlm` state.)
166    #[must_use]
167    pub fn with_events(mut self, events: Arc<dyn EventSink>) -> Self {
168        self.events = Some(events);
169        self
170    }
171
172    /// Set the tools port.
173    #[must_use]
174    pub fn with_tools(mut self, tools: Arc<dyn ToolPort>) -> Self {
175        self.tools = Some(tools);
176        self
177    }
178
179    /// Set the turn policy.
180    #[must_use]
181    pub fn with_policy(mut self, policy: TurnPolicy) -> Self {
182        self.policy = policy;
183        self
184    }
185
186    /// Set the dispatch mode.
187    #[must_use]
188    pub fn with_dispatch_mode(mut self, mode: DispatchMode) -> Self {
189        self.dispatch_mode = mode;
190        self
191    }
192
193    /// Add a tool layer.
194    #[must_use]
195    pub fn add_tool_layer(mut self, layer: Arc<dyn ToolLayer>) -> Self {
196        self.tool_layers.push(layer);
197        self
198    }
199
200    /// Set the tool policy port.
201    #[must_use]
202    pub fn with_tool_policy(mut self, policy: Arc<dyn ToolPolicyPort>) -> Self {
203        self.tool_policy = Some(policy);
204        self
205    }
206
207    /// Set the approval port.
208    #[must_use]
209    pub fn with_approval(mut self, approval: Arc<dyn ApprovalPort>) -> Self {
210        self.approval = Some(approval);
211        self
212    }
213
214    /// Set the checkpoint store.
215    #[must_use]
216    pub fn with_checkpoint_store(mut self, store: Arc<dyn TurnCheckpointStorePort>) -> Self {
217        self.checkpoint_store = Some(store);
218        self
219    }
220
221    /// Set the artifact store.
222    #[must_use]
223    pub fn with_artifact_store(mut self, store: Arc<dyn ArtifactStorePort>) -> Self {
224        self.artifact_store = Some(store);
225        self
226    }
227
228    /// Set the cost meter.
229    #[must_use]
230    pub fn with_cost_meter(mut self, meter: Arc<dyn CostMeterPort>) -> Self {
231        self.cost_meter = Some(meter);
232        self
233    }
234
235    /// Set the context compactor.
236    #[must_use]
237    pub fn with_context_compactor(mut self, compactor: Arc<dyn ContextCompactorPort>) -> Self {
238        self.context_compactor = Some(compactor);
239        self
240    }
241
242    /// Set the tool journal.
243    #[must_use]
244    pub fn with_journal(mut self, journal: Arc<dyn ToolJournalPort>) -> Self {
245        self.journal = Some(journal);
246        self
247    }
248}
249
250impl TypedRuntimeBuilder<Ready> {
251    /// Build the runtime. Only available when all required fields are set.
252    pub fn build(self) -> Result<Arc<dyn AgentRuntime>, AgentError> {
253        // type-state guarantees these are Some in Ready state.
254        let Some(llm) = self.llm else {
255            unreachable!("type-state guarantees llm is set in Ready state")
256        };
257        let Some(default_model) = self.default_model else {
258            unreachable!("type-state guarantees default_model is set in Ready state")
259        };
260
261        let store =
262            self.store.ok_or_else(|| AgentError::Config("missing session store".to_string()))?;
263        let events =
264            self.events.ok_or_else(|| AgentError::Config("missing event sink".to_string()))?;
265
266        let tool_policy: Arc<dyn ToolPolicyPort> = self
267            .tool_policy
268            .unwrap_or_else(|| Arc::new(crate::DefaultToolPolicyPort) as Arc<dyn ToolPolicyPort>);
269        let approval: Arc<dyn ApprovalPort> = self
270            .approval
271            .unwrap_or_else(|| Arc::new(crate::AllowAllApprovalPort) as Arc<dyn ApprovalPort>);
272        let checkpoint_store: Arc<dyn TurnCheckpointStorePort> =
273            self.checkpoint_store.unwrap_or_else(|| {
274                Arc::new(crate::NoOpCheckpointStorePort) as Arc<dyn TurnCheckpointStorePort>
275            });
276        let artifact_store: Arc<dyn ArtifactStorePort> = self.artifact_store.unwrap_or_else(|| {
277            Arc::new(crate::NoOpArtifactStorePort) as Arc<dyn ArtifactStorePort>
278        });
279        let cost_meter: Arc<dyn CostMeterPort> = self
280            .cost_meter
281            .unwrap_or_else(|| Arc::new(crate::NoOpCostMeterPort) as Arc<dyn CostMeterPort>);
282        let context_compactor: Arc<dyn ContextCompactorPort> =
283            self.context_compactor.unwrap_or_else(|| {
284                Arc::new(crate::prompt::WindowContextCompactor::default())
285                    as Arc<dyn ContextCompactorPort>
286            });
287        let journal: Arc<dyn ToolJournalPort> = self
288            .journal
289            .unwrap_or_else(|| Arc::new(crate::NoOpToolJournalPort) as Arc<dyn ToolJournalPort>);
290
291        let mut tools: Arc<dyn ToolPort> =
292            self.tools.unwrap_or_else(|| Arc::new(crate::NoOpToolPort) as Arc<dyn ToolPort>);
293        for layer in self.tool_layers {
294            tools = layer.wrap(tools);
295        }
296
297        let rt = DefaultAgentRuntime {
298            llm,
299            tools,
300            store,
301            events,
302            default_model,
303            policy: self.policy,
304            tool_policy,
305            approval,
306            dispatch_mode: self.dispatch_mode,
307            checkpoint_store,
308            artifact_store,
309            cost_meter,
310            context_compactor,
311            journal,
312        };
313        Ok(Arc::new(rt))
314    }
315}
316
317// Allow optional fields on Ready state too.
318impl TypedRuntimeBuilder<Ready> {
319    /// Set the session store. (On Ready, overrides any previous value.)
320    #[must_use]
321    pub fn with_store(mut self, store: Arc<dyn SessionStore>) -> Self {
322        self.store = Some(store);
323        self
324    }
325
326    /// Set the event sink. (On Ready, overrides any previous value.)
327    #[must_use]
328    pub fn with_events(mut self, events: Arc<dyn EventSink>) -> Self {
329        self.events = Some(events);
330        self
331    }
332
333    /// Set the tools port.
334    #[must_use]
335    pub fn with_tools(mut self, tools: Arc<dyn ToolPort>) -> Self {
336        self.tools = Some(tools);
337        self
338    }
339
340    /// Set the turn policy.
341    #[must_use]
342    pub fn with_policy(mut self, policy: TurnPolicy) -> Self {
343        self.policy = policy;
344        self
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use std::sync::Mutex;
351
352    use bob_core::{
353        error::{LlmError, StoreError},
354        types::{
355            FinishReason, LlmCapabilities, LlmRequest, LlmResponse, SessionId, SessionState,
356            TokenUsage,
357        },
358    };
359
360    use super::*;
361
362    struct StubLlm;
363
364    #[async_trait::async_trait]
365    impl LlmPort for StubLlm {
366        fn capabilities(&self) -> LlmCapabilities {
367            LlmCapabilities::default()
368        }
369        async fn complete(&self, _req: LlmRequest) -> Result<LlmResponse, LlmError> {
370            Ok(LlmResponse {
371                content: r#"{"type": "final", "content": "ok"}"#.into(),
372                usage: TokenUsage::default(),
373                finish_reason: FinishReason::Stop,
374                tool_calls: Vec::new(),
375            })
376        }
377        async fn complete_stream(
378            &self,
379            _req: LlmRequest,
380        ) -> Result<bob_core::types::LlmStream, LlmError> {
381            Err(LlmError::Provider("not implemented".into()))
382        }
383    }
384
385    struct StubStore;
386
387    #[async_trait::async_trait]
388    impl SessionStore for StubStore {
389        async fn load(&self, _id: &SessionId) -> Result<Option<SessionState>, StoreError> {
390            Ok(None)
391        }
392        async fn save(&self, _id: &SessionId, _state: &SessionState) -> Result<(), StoreError> {
393            Ok(())
394        }
395    }
396
397    struct StubSink {
398        _count: Mutex<usize>,
399    }
400
401    impl EventSink for StubSink {
402        fn emit(&self, _event: bob_core::types::AgentEvent) {}
403    }
404
405    #[tokio::test]
406    async fn typed_builder_enforces_required_fields() {
407        let runtime = TypedRuntimeBuilder::new()
408            .with_llm(Arc::new(StubLlm))
409            .with_default_model("test-model")
410            .with_store(Arc::new(StubStore))
411            .with_events(Arc::new(StubSink { _count: Mutex::new(0) }))
412            .build()
413            .expect("should build");
414
415        let result = runtime.health().await;
416        assert_eq!(result.status, bob_core::types::HealthStatus::Healthy);
417    }
418
419    #[tokio::test]
420    async fn typed_builder_missing_store_errors() {
421        let result = TypedRuntimeBuilder::new()
422            .with_llm(Arc::new(StubLlm))
423            .with_default_model("test-model")
424            .with_events(Arc::new(StubSink { _count: Mutex::new(0) }))
425            .build();
426
427        assert!(result.is_err(), "missing store should return error");
428    }
429}