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