1pub mod action;
81pub mod composite;
82pub mod prompt;
83pub mod scheduler;
84pub mod tooling;
85
86use std::sync::Arc;
87
88pub use bob_core as core;
89use bob_core::{
90 error::{AgentError, CostError, StoreError, ToolError},
91 ports::{
92 ApprovalPort, ArtifactStorePort, CostMeterPort, EventSink, LlmPort, SessionStore,
93 ToolPolicyPort, ToolPort, TurnCheckpointStorePort,
94 },
95 types::{
96 AgentEventStream, AgentRequest, AgentRunResult, ApprovalContext, ApprovalDecision,
97 ArtifactRecord, HealthStatus, RuntimeHealth, SessionId, ToolCall, ToolResult,
98 TurnCheckpoint, TurnPolicy,
99 },
100};
101pub use tooling::{NoOpToolPort, TimeoutToolLayer, ToolLayer};
102
103#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
105pub enum DispatchMode {
106 PromptGuided,
108 #[default]
110 NativePreferred,
111}
112
113#[derive(Debug, Clone, Copy, Default)]
115pub(crate) struct DefaultToolPolicyPort;
116
117impl ToolPolicyPort for DefaultToolPolicyPort {
118 fn is_tool_allowed(
119 &self,
120 tool: &str,
121 deny_tools: &[String],
122 allow_tools: Option<&[String]>,
123 ) -> bool {
124 bob_core::is_tool_allowed(tool, deny_tools, allow_tools)
125 }
126}
127
128#[derive(Debug, Clone, Copy, Default)]
130pub(crate) struct AllowAllApprovalPort;
131
132#[async_trait::async_trait]
133impl ApprovalPort for AllowAllApprovalPort {
134 async fn approve_tool_call(
135 &self,
136 _call: &ToolCall,
137 _context: &ApprovalContext,
138 ) -> Result<ApprovalDecision, ToolError> {
139 Ok(ApprovalDecision::Approved)
140 }
141}
142
143#[derive(Debug, Clone, Copy, Default)]
145pub(crate) struct NoOpCheckpointStorePort;
146
147#[async_trait::async_trait]
148impl TurnCheckpointStorePort for NoOpCheckpointStorePort {
149 async fn save_checkpoint(&self, _checkpoint: &TurnCheckpoint) -> Result<(), StoreError> {
150 Ok(())
151 }
152
153 async fn load_latest(
154 &self,
155 _session_id: &SessionId,
156 ) -> Result<Option<TurnCheckpoint>, StoreError> {
157 Ok(None)
158 }
159}
160
161#[derive(Debug, Clone, Copy, Default)]
163pub(crate) struct NoOpArtifactStorePort;
164
165#[async_trait::async_trait]
166impl ArtifactStorePort for NoOpArtifactStorePort {
167 async fn put(&self, _artifact: ArtifactRecord) -> Result<(), StoreError> {
168 Ok(())
169 }
170
171 async fn list_by_session(
172 &self,
173 _session_id: &SessionId,
174 ) -> Result<Vec<ArtifactRecord>, StoreError> {
175 Ok(Vec::new())
176 }
177}
178
179#[derive(Debug, Clone, Copy, Default)]
181pub(crate) struct NoOpCostMeterPort;
182
183#[async_trait::async_trait]
184impl CostMeterPort for NoOpCostMeterPort {
185 async fn check_budget(&self, _session_id: &SessionId) -> Result<(), CostError> {
186 Ok(())
187 }
188
189 async fn record_llm_usage(
190 &self,
191 _session_id: &SessionId,
192 _model: &str,
193 _usage: &bob_core::types::TokenUsage,
194 ) -> Result<(), CostError> {
195 Ok(())
196 }
197
198 async fn record_tool_result(
199 &self,
200 _session_id: &SessionId,
201 _tool_result: &ToolResult,
202 ) -> Result<(), CostError> {
203 Ok(())
204 }
205}
206
207pub trait AgentBootstrap: Send {
211 fn build(self) -> Result<Arc<dyn AgentRuntime>, AgentError>
213 where
214 Self: Sized;
215}
216
217#[derive(Default)]
221pub struct RuntimeBuilder {
222 llm: Option<Arc<dyn LlmPort>>,
223 tools: Option<Arc<dyn ToolPort>>,
224 store: Option<Arc<dyn SessionStore>>,
225 events: Option<Arc<dyn EventSink>>,
226 default_model: Option<String>,
227 policy: TurnPolicy,
228 tool_layers: Vec<Arc<dyn ToolLayer>>,
229 tool_policy: Option<Arc<dyn ToolPolicyPort>>,
230 approval: Option<Arc<dyn ApprovalPort>>,
231 dispatch_mode: DispatchMode,
232 checkpoint_store: Option<Arc<dyn TurnCheckpointStorePort>>,
233 artifact_store: Option<Arc<dyn ArtifactStorePort>>,
234 cost_meter: Option<Arc<dyn CostMeterPort>>,
235}
236
237impl std::fmt::Debug for RuntimeBuilder {
238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 f.debug_struct("RuntimeBuilder")
240 .field("has_llm", &self.llm.is_some())
241 .field("has_tools", &self.tools.is_some())
242 .field("has_store", &self.store.is_some())
243 .field("has_events", &self.events.is_some())
244 .field("default_model", &self.default_model)
245 .field("policy", &self.policy)
246 .field("tool_layers", &self.tool_layers.len())
247 .field("has_tool_policy", &self.tool_policy.is_some())
248 .field("has_approval", &self.approval.is_some())
249 .field("dispatch_mode", &self.dispatch_mode)
250 .field("has_checkpoint_store", &self.checkpoint_store.is_some())
251 .field("has_artifact_store", &self.artifact_store.is_some())
252 .field("has_cost_meter", &self.cost_meter.is_some())
253 .finish()
254 }
255}
256
257impl RuntimeBuilder {
258 #[must_use]
259 pub fn new() -> Self {
260 Self::default()
261 }
262
263 #[must_use]
264 pub fn with_llm(mut self, llm: Arc<dyn LlmPort>) -> Self {
265 self.llm = Some(llm);
266 self
267 }
268
269 #[must_use]
270 pub fn with_tools(mut self, tools: Arc<dyn ToolPort>) -> Self {
271 self.tools = Some(tools);
272 self
273 }
274
275 #[must_use]
276 pub fn with_store(mut self, store: Arc<dyn SessionStore>) -> Self {
277 self.store = Some(store);
278 self
279 }
280
281 #[must_use]
282 pub fn with_events(mut self, events: Arc<dyn EventSink>) -> Self {
283 self.events = Some(events);
284 self
285 }
286
287 #[must_use]
288 pub fn with_default_model(mut self, default_model: impl Into<String>) -> Self {
289 self.default_model = Some(default_model.into());
290 self
291 }
292
293 #[must_use]
294 pub fn with_policy(mut self, policy: TurnPolicy) -> Self {
295 self.policy = policy;
296 self
297 }
298
299 #[must_use]
300 pub fn with_tool_policy(mut self, tool_policy: Arc<dyn ToolPolicyPort>) -> Self {
301 self.tool_policy = Some(tool_policy);
302 self
303 }
304
305 #[must_use]
306 pub fn with_approval(mut self, approval: Arc<dyn ApprovalPort>) -> Self {
307 self.approval = Some(approval);
308 self
309 }
310
311 #[must_use]
312 pub fn with_dispatch_mode(mut self, dispatch_mode: DispatchMode) -> Self {
313 self.dispatch_mode = dispatch_mode;
314 self
315 }
316
317 #[must_use]
318 pub fn with_checkpoint_store(
319 mut self,
320 checkpoint_store: Arc<dyn TurnCheckpointStorePort>,
321 ) -> Self {
322 self.checkpoint_store = Some(checkpoint_store);
323 self
324 }
325
326 #[must_use]
327 pub fn with_artifact_store(mut self, artifact_store: Arc<dyn ArtifactStorePort>) -> Self {
328 self.artifact_store = Some(artifact_store);
329 self
330 }
331
332 #[must_use]
333 pub fn with_cost_meter(mut self, cost_meter: Arc<dyn CostMeterPort>) -> Self {
334 self.cost_meter = Some(cost_meter);
335 self
336 }
337
338 #[must_use]
339 pub fn add_tool_layer(mut self, layer: Arc<dyn ToolLayer>) -> Self {
340 self.tool_layers.push(layer);
341 self
342 }
343
344 fn into_runtime(self) -> Result<Arc<dyn AgentRuntime>, AgentError> {
345 let llm = self.llm.ok_or_else(|| AgentError::Config("missing LLM port".to_string()))?;
346 let store =
347 self.store.ok_or_else(|| AgentError::Config("missing session store".to_string()))?;
348 let events =
349 self.events.ok_or_else(|| AgentError::Config("missing event sink".to_string()))?;
350 let default_model = self
351 .default_model
352 .ok_or_else(|| AgentError::Config("missing default model".to_string()))?;
353 let tool_policy: Arc<dyn ToolPolicyPort> = self
354 .tool_policy
355 .unwrap_or_else(|| Arc::new(DefaultToolPolicyPort) as Arc<dyn ToolPolicyPort>);
356 let approval: Arc<dyn ApprovalPort> = self
357 .approval
358 .unwrap_or_else(|| Arc::new(AllowAllApprovalPort) as Arc<dyn ApprovalPort>);
359 let checkpoint_store: Arc<dyn TurnCheckpointStorePort> =
360 self.checkpoint_store.unwrap_or_else(|| {
361 Arc::new(NoOpCheckpointStorePort) as Arc<dyn TurnCheckpointStorePort>
362 });
363 let artifact_store: Arc<dyn ArtifactStorePort> = self
364 .artifact_store
365 .unwrap_or_else(|| Arc::new(NoOpArtifactStorePort) as Arc<dyn ArtifactStorePort>);
366 let cost_meter: Arc<dyn CostMeterPort> = self
367 .cost_meter
368 .unwrap_or_else(|| Arc::new(NoOpCostMeterPort) as Arc<dyn CostMeterPort>);
369
370 let mut tools: Arc<dyn ToolPort> =
371 self.tools.unwrap_or_else(|| Arc::new(NoOpToolPort) as Arc<dyn ToolPort>);
372 for layer in self.tool_layers {
373 tools = layer.wrap(tools);
374 }
375
376 let rt = DefaultAgentRuntime {
377 llm,
378 tools,
379 store,
380 events,
381 default_model,
382 policy: self.policy,
383 tool_policy,
384 approval,
385 dispatch_mode: self.dispatch_mode,
386 checkpoint_store,
387 artifact_store,
388 cost_meter,
389 };
390 Ok(Arc::new(rt))
391 }
392}
393
394impl AgentBootstrap for RuntimeBuilder {
395 fn build(self) -> Result<Arc<dyn AgentRuntime>, AgentError>
396 where
397 Self: Sized,
398 {
399 self.into_runtime()
400 }
401}
402
403#[async_trait::async_trait]
407pub trait AgentRuntime: Send + Sync {
408 async fn run(&self, req: AgentRequest) -> Result<AgentRunResult, AgentError>;
410
411 async fn run_stream(&self, req: AgentRequest) -> Result<AgentEventStream, AgentError>;
413
414 async fn health(&self) -> RuntimeHealth;
416}
417
418pub struct DefaultAgentRuntime {
422 pub llm: Arc<dyn LlmPort>,
423 pub tools: Arc<dyn ToolPort>,
424 pub store: Arc<dyn SessionStore>,
425 pub events: Arc<dyn EventSink>,
426 pub default_model: String,
427 pub policy: TurnPolicy,
428 pub tool_policy: Arc<dyn ToolPolicyPort>,
429 pub approval: Arc<dyn ApprovalPort>,
430 pub dispatch_mode: DispatchMode,
431 pub checkpoint_store: Arc<dyn TurnCheckpointStorePort>,
432 pub artifact_store: Arc<dyn ArtifactStorePort>,
433 pub cost_meter: Arc<dyn CostMeterPort>,
434}
435
436impl std::fmt::Debug for DefaultAgentRuntime {
437 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
438 f.debug_struct("DefaultAgentRuntime").finish_non_exhaustive()
439 }
440}
441
442#[async_trait::async_trait]
443impl AgentRuntime for DefaultAgentRuntime {
444 async fn run(&self, req: AgentRequest) -> Result<AgentRunResult, AgentError> {
445 scheduler::run_turn_with_extensions(
446 self.llm.as_ref(),
447 self.tools.as_ref(),
448 self.store.as_ref(),
449 self.events.as_ref(),
450 req,
451 &self.policy,
452 &self.default_model,
453 self.tool_policy.as_ref(),
454 self.approval.as_ref(),
455 self.dispatch_mode,
456 self.checkpoint_store.as_ref(),
457 self.artifact_store.as_ref(),
458 self.cost_meter.as_ref(),
459 )
460 .await
461 }
462
463 async fn run_stream(&self, req: AgentRequest) -> Result<AgentEventStream, AgentError> {
464 scheduler::run_turn_stream_with_controls(
465 self.llm.clone(),
466 self.tools.clone(),
467 self.store.clone(),
468 self.events.clone(),
469 req,
470 self.policy.clone(),
471 self.default_model.clone(),
472 self.tool_policy.clone(),
473 self.approval.clone(),
474 self.dispatch_mode,
475 self.checkpoint_store.clone(),
476 self.artifact_store.clone(),
477 self.cost_meter.clone(),
478 )
479 .await
480 }
481
482 async fn health(&self) -> RuntimeHealth {
483 RuntimeHealth { status: HealthStatus::Healthy, llm_ready: true, mcp_pool_ready: true }
484 }
485}
486
487#[cfg(test)]
490mod tests {
491 use std::sync::Mutex;
492
493 use bob_core::{
494 error::{LlmError, StoreError, ToolError},
495 types::*,
496 };
497
498 use super::*;
499
500 struct StubLlm;
503
504 #[async_trait::async_trait]
505 impl LlmPort for StubLlm {
506 async fn complete(&self, _req: LlmRequest) -> Result<LlmResponse, LlmError> {
507 Ok(LlmResponse {
508 content: r#"{"type": "final", "content": "stub response"}"#.into(),
509 usage: TokenUsage::default(),
510 finish_reason: FinishReason::Stop,
511 tool_calls: Vec::new(),
512 })
513 }
514
515 async fn complete_stream(&self, _req: LlmRequest) -> Result<LlmStream, LlmError> {
516 Err(LlmError::Provider("not implemented".into()))
517 }
518 }
519
520 struct StubTools;
521
522 #[async_trait::async_trait]
523 impl ToolPort for StubTools {
524 async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError> {
525 Ok(vec![])
526 }
527
528 async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError> {
529 Ok(ToolResult { name: call.name, output: serde_json::json!(null), is_error: false })
530 }
531 }
532
533 struct StubStore;
534
535 #[async_trait::async_trait]
536 impl SessionStore for StubStore {
537 async fn load(&self, _id: &SessionId) -> Result<Option<SessionState>, StoreError> {
538 Ok(None)
539 }
540
541 async fn save(&self, _id: &SessionId, _state: &SessionState) -> Result<(), StoreError> {
542 Ok(())
543 }
544 }
545
546 struct StubSink {
547 count: Mutex<usize>,
548 }
549
550 impl EventSink for StubSink {
551 fn emit(&self, _event: AgentEvent) {
552 let mut count = self.count.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
553 *count += 1;
554 }
555 }
556
557 #[tokio::test]
558 async fn default_runtime_run() {
559 let rt: Arc<dyn AgentRuntime> = Arc::new(DefaultAgentRuntime {
560 llm: Arc::new(StubLlm),
561 tools: Arc::new(StubTools),
562 store: Arc::new(StubStore),
563 events: Arc::new(StubSink { count: Mutex::new(0) }),
564 default_model: "test-model".into(),
565 policy: TurnPolicy::default(),
566 tool_policy: Arc::new(DefaultToolPolicyPort),
567 approval: Arc::new(AllowAllApprovalPort),
568 dispatch_mode: DispatchMode::PromptGuided,
569 checkpoint_store: Arc::new(NoOpCheckpointStorePort),
570 artifact_store: Arc::new(NoOpArtifactStorePort),
571 cost_meter: Arc::new(NoOpCostMeterPort),
572 });
573
574 let req = AgentRequest {
575 input: "hello".into(),
576 session_id: "test".into(),
577 model: None,
578 context: RequestContext::default(),
579 cancel_token: None,
580 };
581
582 let result = rt.run(req).await;
583 assert!(
584 matches!(result, Ok(AgentRunResult::Finished(_))),
585 "run should finish successfully"
586 );
587 if let Ok(AgentRunResult::Finished(resp)) = result {
588 assert_eq!(resp.finish_reason, FinishReason::Stop);
589 assert_eq!(resp.content, "stub response");
590 }
591 }
592
593 #[tokio::test]
594 async fn default_runtime_health() {
595 let rt = DefaultAgentRuntime {
596 llm: Arc::new(StubLlm),
597 tools: Arc::new(StubTools),
598 store: Arc::new(StubStore),
599 events: Arc::new(StubSink { count: Mutex::new(0) }),
600 default_model: "test-model".into(),
601 policy: TurnPolicy::default(),
602 tool_policy: Arc::new(DefaultToolPolicyPort),
603 approval: Arc::new(AllowAllApprovalPort),
604 dispatch_mode: DispatchMode::PromptGuided,
605 checkpoint_store: Arc::new(NoOpCheckpointStorePort),
606 artifact_store: Arc::new(NoOpArtifactStorePort),
607 cost_meter: Arc::new(NoOpCostMeterPort),
608 };
609
610 let health = rt.health().await;
611 assert_eq!(health.status, HealthStatus::Healthy);
612 }
613
614 #[tokio::test]
615 async fn runtime_builder_requires_core_dependencies() {
616 let result = RuntimeBuilder::new().build();
617 assert!(
618 matches!(result, Err(AgentError::Config(msg)) if msg.contains("missing LLM")),
619 "missing llm should return config error"
620 );
621 }
622}