Skip to main content

bob_runtime/
lib.rs

1//! # Bob Runtime
2//!
3//! Runtime orchestration layer for the [Bob Agent Framework](https://github.com/longcipher/bob).
4//!
5//! ## Overview
6//!
7//! This crate provides the orchestration layer that coordinates agent execution:
8//!
9//! - **Scheduler**: Finite state machine for agent turn execution
10//! - **Action Parser**: Parses LLM responses into structured actions
11//! - **Prompt Builder**: Constructs prompts with tool definitions and context
12//! - **Composite Tool Port**: Aggregates multiple tool sources
13//!
14//! This crate depends **only** on [`bob_core`] port traits — never on concrete adapters.
15//!
16//! ## Architecture
17//!
18//! ```text
19//! ┌─────────────────────────────────────────┐
20//! │         AgentRuntime (trait)            │
21//! ├─────────────────────────────────────────┤
22//! │  ┌──────────┐  ┌──────────┐  ┌───────┐ │
23//! │  │Scheduler │→ │Prompt    │→ │Action │ │
24//! │  │  FSM     │  │Builder   │  │Parser │ │
25//! │  └──────────┘  └──────────┘  └───────┘ │
26//! └─────────────────────────────────────────┘
27//!          ↓ uses ports from bob_core
28//! ```
29//!
30//! ## Example
31//!
32//! ```rust,ignore
33//! use bob_runtime::{AgentBootstrap, AgentRuntime, RuntimeBuilder};
34//! use bob_core::{
35//!     ports::{LlmPort, ToolPort, SessionStore, EventSink},
36//!     types::TurnPolicy,
37//! };
38//! use std::sync::Arc;
39//!
40//! fn create_runtime(
41//!     llm: Arc<dyn LlmPort>,
42//!     tools: Arc<dyn ToolPort>,
43//!     store: Arc<dyn SessionStore>,
44//!     events: Arc<dyn EventSink>,
45//! ) -> Result<Arc<dyn AgentRuntime>, bob_core::error::AgentError> {
46//!     RuntimeBuilder::new()
47//!         .with_llm(llm)
48//!         .with_tools(tools)
49//!         .with_store(store)
50//!         .with_events(events)
51//!         .with_default_model("openai:gpt-4o-mini")
52//!         .with_policy(TurnPolicy::default())
53//!         .build()
54//! }
55//! ```
56//!
57//! ## Features
58//!
59//! - **Finite State Machine**: Robust turn execution with state tracking
60//! - **Streaming Support**: Real-time event streaming via `run_stream()`
61//! - **Tool Composition**: Aggregate multiple MCP servers or tool sources
62//! - **Turn Policies**: Configurable limits for steps, timeouts, and retries
63//! - **Health Monitoring**: Built-in health check endpoints
64//!
65//! ## Modules
66//!
67//! - [`scheduler`] - Core FSM implementation for agent execution
68//! - [`action`] - Action types and parser for LLM responses
69//! - [`prompt`] - Prompt construction and tool definition formatting
70//! - [`composite`] - Multi-source tool aggregation
71//!
72//! ## Related Crates
73//!
74//! - [`bob_core`] - Domain types and ports
75//! - [`bob_adapters`] - Concrete implementations
76//!
77//! [`bob_core`]: https://docs.rs/bob-core
78//! [`bob_adapters`]: https://docs.rs/bob-adapters
79
80pub 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/// Action dispatch mode for model responses.
107#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
108pub enum DispatchMode {
109    /// Use prompt-guided JSON action protocol only.
110    PromptGuided,
111    /// Prefer native provider tool calls when available, fallback to prompt-guided parsing.
112    #[default]
113    NativePreferred,
114}
115
116/// Default static policy implementation backed by `bob-core` tool matching helpers.
117#[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/// Default approval implementation that allows every tool call.
132#[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/// Default checkpoint store that drops all checkpoints.
147#[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/// Default artifact store that drops all artifacts.
165#[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/// Default cost meter that never blocks and records nothing.
183#[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
210// ── Bootstrap / Builder ───────────────────────────────────────────────
211
212/// Bootstrap contract for producing an [`AgentRuntime`].
213pub trait AgentBootstrap: Send {
214    /// Consume the builder and produce a ready-to-use runtime.
215    fn build(self) -> Result<Arc<dyn AgentRuntime>, AgentError>
216    where
217        Self: Sized;
218}
219
220/// Trait-first runtime builder used by composition roots.
221///
222/// This keeps wiring explicit while avoiding a monolithic `main.rs`.
223#[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// ── Runtime Trait ────────────────────────────────────────────────────
407
408/// The primary API for running agent turns.
409#[async_trait::async_trait]
410pub trait AgentRuntime: Send + Sync {
411    /// Execute a single agent turn (blocking until complete).
412    async fn run(&self, req: AgentRequest) -> Result<AgentRunResult, AgentError>;
413
414    /// Execute a single agent turn with streaming events.
415    async fn run_stream(&self, req: AgentRequest) -> Result<AgentEventStream, AgentError>;
416
417    /// Check runtime health.
418    async fn health(&self) -> RuntimeHealth;
419}
420
421// ── Default Implementation ───────────────────────────────────────────
422
423/// Default runtime that composes the 4 port traits via `Arc<dyn ...>`.
424pub 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// ── Tests ────────────────────────────────────────────────────────────
491
492#[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    // Minimal mock implementations for testing the runtime wiring.
504
505    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}