1use 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#[derive(Debug, Clone, Copy, Default)]
37pub struct Empty;
38
39#[derive(Debug, Clone, Copy, Default)]
41pub struct HasLlm;
42
43#[derive(Debug, Clone, Copy, Default)]
45pub struct Ready;
46
47#[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 #[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 #[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 #[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 #[must_use]
160 pub fn with_store(mut self, store: Arc<dyn SessionStore>) -> Self {
161 self.store = Some(store);
162 self
163 }
164
165 #[must_use]
167 pub fn with_events(mut self, events: Arc<dyn EventSink>) -> Self {
168 self.events = Some(events);
169 self
170 }
171
172 #[must_use]
174 pub fn with_tools(mut self, tools: Arc<dyn ToolPort>) -> Self {
175 self.tools = Some(tools);
176 self
177 }
178
179 #[must_use]
181 pub fn with_policy(mut self, policy: TurnPolicy) -> Self {
182 self.policy = policy;
183 self
184 }
185
186 #[must_use]
188 pub fn with_dispatch_mode(mut self, mode: DispatchMode) -> Self {
189 self.dispatch_mode = mode;
190 self
191 }
192
193 #[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 #[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 #[must_use]
209 pub fn with_approval(mut self, approval: Arc<dyn ApprovalPort>) -> Self {
210 self.approval = Some(approval);
211 self
212 }
213
214 #[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 #[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 #[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 #[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 #[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 pub fn build(self) -> Result<Arc<dyn AgentRuntime>, AgentError> {
253 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
317impl TypedRuntimeBuilder<Ready> {
319 #[must_use]
321 pub fn with_store(mut self, store: Arc<dyn SessionStore>) -> Self {
322 self.store = Some(store);
323 self
324 }
325
326 #[must_use]
328 pub fn with_events(mut self, events: Arc<dyn EventSink>) -> Self {
329 self.events = Some(events);
330 self
331 }
332
333 #[must_use]
335 pub fn with_tools(mut self, tools: Arc<dyn ToolPort>) -> Self {
336 self.tools = Some(tools);
337 self
338 }
339
340 #[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}