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 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/// Action dispatch mode for model responses.
104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
105pub enum DispatchMode {
106    /// Use prompt-guided JSON action protocol only.
107    PromptGuided,
108    /// Prefer native provider tool calls when available, fallback to prompt-guided parsing.
109    #[default]
110    NativePreferred,
111}
112
113/// Default static policy implementation backed by `bob-core` tool matching helpers.
114#[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/// Default approval implementation that allows every tool call.
129#[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/// Default checkpoint store that drops all checkpoints.
144#[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/// Default artifact store that drops all artifacts.
162#[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/// Default cost meter that never blocks and records nothing.
180#[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
207// ── Bootstrap / Builder ───────────────────────────────────────────────
208
209/// Bootstrap contract for producing an [`AgentRuntime`].
210pub trait AgentBootstrap: Send {
211    /// Consume the builder and produce a ready-to-use runtime.
212    fn build(self) -> Result<Arc<dyn AgentRuntime>, AgentError>
213    where
214        Self: Sized;
215}
216
217/// Trait-first runtime builder used by composition roots.
218///
219/// This keeps wiring explicit while avoiding a monolithic `main.rs`.
220#[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// ── Runtime Trait ────────────────────────────────────────────────────
404
405/// The primary API for running agent turns.
406#[async_trait::async_trait]
407pub trait AgentRuntime: Send + Sync {
408    /// Execute a single agent turn (blocking until complete).
409    async fn run(&self, req: AgentRequest) -> Result<AgentRunResult, AgentError>;
410
411    /// Execute a single agent turn with streaming events.
412    async fn run_stream(&self, req: AgentRequest) -> Result<AgentEventStream, AgentError>;
413
414    /// Check runtime health.
415    async fn health(&self) -> RuntimeHealth;
416}
417
418// ── Default Implementation ───────────────────────────────────────────
419
420/// Default runtime that composes the 4 port traits via `Arc<dyn ...>`.
421pub 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// ── Tests ────────────────────────────────────────────────────────────
488
489#[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    // Minimal mock implementations for testing the runtime wiring.
501
502    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}