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
80#![forbid(unsafe_code)]
81
82pub mod action;
83pub mod agent_loop;
84pub mod classifier;
85pub mod composite;
86pub mod message_bus;
87pub mod output_validation;
88pub mod progressive_tools;
89pub mod prompt;
90pub mod router;
91pub mod scheduler;
92pub mod session;
93pub mod subagent;
94pub mod tooling;
95pub mod tower_service;
96pub mod typed_builder;
97pub mod typestate;
98
99use std::sync::Arc;
100
101pub use bob_core as core;
102use bob_core::{
103    error::{AgentError, CostError, StoreError, ToolError},
104    journal::{JournalEntry, ToolJournalPort},
105    ports::{
106        ApprovalPort, ArtifactStorePort, ContextCompactorPort, CostMeterPort, EventSink, LlmPort,
107        SessionStore, ToolPolicyPort, ToolPort, TurnCheckpointStorePort,
108    },
109    types::{
110        AgentEventStream, AgentRequest, AgentRunResult, ApprovalContext, ApprovalDecision,
111        ArtifactRecord, HealthStatus, RuntimeHealth, SessionId, ToolCall, ToolResult,
112        TurnCheckpoint, TurnPolicy,
113    },
114};
115pub use session::{Agent, AgentBuilder, AgentResponse, Session};
116pub use tooling::{NoOpToolPort, TimeoutToolLayer, ToolLayer};
117pub use tower_service::{
118    LlmPortServiceExt, LlmRequestWrapper, LlmResponseWrapper, LlmService, ServiceExt,
119    ToolListRequest, ToolListService, ToolPortServiceExt, ToolRequest, ToolResponse, ToolService,
120};
121pub use typestate::{
122    AgentRunner, AgentStepResult, AwaitingToolCall, Finished, Ready, RunnerContext,
123};
124
125/// Action dispatch mode for model responses.
126#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
127pub enum DispatchMode {
128    /// Use prompt-guided JSON action protocol only.
129    PromptGuided,
130    /// Prefer native provider tool calls when available, fallback to prompt-guided parsing.
131    #[default]
132    NativePreferred,
133}
134
135/// Default static policy implementation backed by `bob-core` tool matching helpers.
136#[derive(Debug, Clone, Copy, Default)]
137pub(crate) struct DefaultToolPolicyPort;
138
139impl ToolPolicyPort for DefaultToolPolicyPort {
140    fn is_tool_allowed(
141        &self,
142        tool: &str,
143        deny_tools: &[String],
144        allow_tools: Option<&[String]>,
145    ) -> bool {
146        bob_core::is_tool_allowed(tool, deny_tools, allow_tools)
147    }
148}
149
150/// Default approval implementation that allows every tool call.
151#[derive(Debug, Clone, Copy, Default)]
152pub(crate) struct AllowAllApprovalPort;
153
154#[async_trait::async_trait]
155impl ApprovalPort for AllowAllApprovalPort {
156    async fn approve_tool_call(
157        &self,
158        _call: &ToolCall,
159        _context: &ApprovalContext,
160    ) -> Result<ApprovalDecision, ToolError> {
161        Ok(ApprovalDecision::Approved)
162    }
163}
164
165/// Default checkpoint store that drops all checkpoints.
166#[derive(Debug, Clone, Copy, Default)]
167pub(crate) struct NoOpCheckpointStorePort;
168
169#[async_trait::async_trait]
170impl TurnCheckpointStorePort for NoOpCheckpointStorePort {
171    async fn save_checkpoint(&self, _checkpoint: &TurnCheckpoint) -> Result<(), StoreError> {
172        Ok(())
173    }
174
175    async fn load_latest(
176        &self,
177        _session_id: &SessionId,
178    ) -> Result<Option<TurnCheckpoint>, StoreError> {
179        Ok(None)
180    }
181}
182
183/// Default artifact store that drops all artifacts.
184#[derive(Debug, Clone, Copy, Default)]
185pub(crate) struct NoOpArtifactStorePort;
186
187#[async_trait::async_trait]
188impl ArtifactStorePort for NoOpArtifactStorePort {
189    async fn put(&self, _artifact: ArtifactRecord) -> Result<(), StoreError> {
190        Ok(())
191    }
192
193    async fn list_by_session(
194        &self,
195        _session_id: &SessionId,
196    ) -> Result<Vec<ArtifactRecord>, StoreError> {
197        Ok(Vec::new())
198    }
199}
200
201/// Default cost meter that never blocks and records nothing.
202#[derive(Debug, Clone, Copy, Default)]
203pub(crate) struct NoOpCostMeterPort;
204
205#[async_trait::async_trait]
206impl CostMeterPort for NoOpCostMeterPort {
207    async fn check_budget(&self, _session_id: &SessionId) -> Result<(), CostError> {
208        Ok(())
209    }
210
211    async fn record_llm_usage(
212        &self,
213        _session_id: &SessionId,
214        _model: &str,
215        _usage: &bob_core::types::TokenUsage,
216    ) -> Result<(), CostError> {
217        Ok(())
218    }
219
220    async fn record_tool_result(
221        &self,
222        _session_id: &SessionId,
223        _tool_result: &ToolResult,
224    ) -> Result<(), CostError> {
225        Ok(())
226    }
227}
228
229/// Default journal that never records or replays.
230#[derive(Debug, Clone, Copy, Default)]
231pub(crate) struct NoOpToolJournalPort;
232
233#[async_trait::async_trait]
234impl ToolJournalPort for NoOpToolJournalPort {
235    async fn append(&self, _entry: JournalEntry) -> Result<(), StoreError> {
236        Ok(())
237    }
238
239    async fn lookup(
240        &self,
241        _session_id: &SessionId,
242        _fingerprint: &str,
243    ) -> Result<Option<JournalEntry>, StoreError> {
244        Ok(None)
245    }
246
247    async fn entries(&self, _session_id: &SessionId) -> Result<Vec<JournalEntry>, StoreError> {
248        Ok(Vec::new())
249    }
250}
251
252// ── Bootstrap / Builder ───────────────────────────────────────────────
253
254/// Bootstrap contract for producing an [`AgentRuntime`].
255pub trait AgentBootstrap: Send {
256    /// Consume the builder and produce a ready-to-use runtime.
257    fn build(self) -> Result<Arc<dyn AgentRuntime>, AgentError>
258    where
259        Self: Sized;
260}
261
262/// Trait-first runtime builder used by composition roots.
263///
264/// This keeps wiring explicit while avoiding a monolithic `main.rs`.
265#[derive(Default)]
266pub struct RuntimeBuilder {
267    llm: Option<Arc<dyn LlmPort>>,
268    tools: Option<Arc<dyn ToolPort>>,
269    store: Option<Arc<dyn SessionStore>>,
270    events: Option<Arc<dyn EventSink>>,
271    default_model: Option<String>,
272    policy: TurnPolicy,
273    tool_layers: Vec<Arc<dyn ToolLayer>>,
274    tool_policy: Option<Arc<dyn ToolPolicyPort>>,
275    approval: Option<Arc<dyn ApprovalPort>>,
276    dispatch_mode: DispatchMode,
277    checkpoint_store: Option<Arc<dyn TurnCheckpointStorePort>>,
278    artifact_store: Option<Arc<dyn ArtifactStorePort>>,
279    cost_meter: Option<Arc<dyn CostMeterPort>>,
280    context_compactor: Option<Arc<dyn ContextCompactorPort>>,
281    journal: Option<Arc<dyn ToolJournalPort>>,
282}
283
284impl std::fmt::Debug for RuntimeBuilder {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        f.debug_struct("RuntimeBuilder")
287            .field("has_llm", &self.llm.is_some())
288            .field("has_tools", &self.tools.is_some())
289            .field("has_store", &self.store.is_some())
290            .field("has_events", &self.events.is_some())
291            .field("default_model", &self.default_model)
292            .field("policy", &self.policy)
293            .field("tool_layers", &self.tool_layers.len())
294            .field("has_tool_policy", &self.tool_policy.is_some())
295            .field("has_approval", &self.approval.is_some())
296            .field("dispatch_mode", &self.dispatch_mode)
297            .field("has_checkpoint_store", &self.checkpoint_store.is_some())
298            .field("has_artifact_store", &self.artifact_store.is_some())
299            .field("has_cost_meter", &self.cost_meter.is_some())
300            .field("has_context_compactor", &self.context_compactor.is_some())
301            .field("has_journal", &self.journal.is_some())
302            .finish()
303    }
304}
305
306impl RuntimeBuilder {
307    #[must_use]
308    pub fn new() -> Self {
309        Self::default()
310    }
311
312    #[must_use]
313    pub fn with_llm(mut self, llm: Arc<dyn LlmPort>) -> Self {
314        self.llm = Some(llm);
315        self
316    }
317
318    #[must_use]
319    pub fn with_tools(mut self, tools: Arc<dyn ToolPort>) -> Self {
320        self.tools = Some(tools);
321        self
322    }
323
324    #[must_use]
325    pub fn with_store(mut self, store: Arc<dyn SessionStore>) -> Self {
326        self.store = Some(store);
327        self
328    }
329
330    #[must_use]
331    pub fn with_events(mut self, events: Arc<dyn EventSink>) -> Self {
332        self.events = Some(events);
333        self
334    }
335
336    #[must_use]
337    pub fn with_default_model(mut self, default_model: impl Into<String>) -> Self {
338        self.default_model = Some(default_model.into());
339        self
340    }
341
342    #[must_use]
343    pub fn with_policy(mut self, policy: TurnPolicy) -> Self {
344        self.policy = policy;
345        self
346    }
347
348    #[must_use]
349    pub fn with_tool_policy(mut self, tool_policy: Arc<dyn ToolPolicyPort>) -> Self {
350        self.tool_policy = Some(tool_policy);
351        self
352    }
353
354    #[must_use]
355    pub fn with_approval(mut self, approval: Arc<dyn ApprovalPort>) -> Self {
356        self.approval = Some(approval);
357        self
358    }
359
360    #[must_use]
361    pub fn with_dispatch_mode(mut self, dispatch_mode: DispatchMode) -> Self {
362        self.dispatch_mode = dispatch_mode;
363        self
364    }
365
366    #[must_use]
367    pub fn with_checkpoint_store(
368        mut self,
369        checkpoint_store: Arc<dyn TurnCheckpointStorePort>,
370    ) -> Self {
371        self.checkpoint_store = Some(checkpoint_store);
372        self
373    }
374
375    #[must_use]
376    pub fn with_artifact_store(mut self, artifact_store: Arc<dyn ArtifactStorePort>) -> Self {
377        self.artifact_store = Some(artifact_store);
378        self
379    }
380
381    #[must_use]
382    pub fn with_cost_meter(mut self, cost_meter: Arc<dyn CostMeterPort>) -> Self {
383        self.cost_meter = Some(cost_meter);
384        self
385    }
386
387    #[must_use]
388    pub fn with_context_compactor(
389        mut self,
390        context_compactor: Arc<dyn ContextCompactorPort>,
391    ) -> Self {
392        self.context_compactor = Some(context_compactor);
393        self
394    }
395
396    #[must_use]
397    pub fn with_journal(mut self, journal: Arc<dyn ToolJournalPort>) -> Self {
398        self.journal = Some(journal);
399        self
400    }
401
402    #[must_use]
403    pub fn add_tool_layer(mut self, layer: Arc<dyn ToolLayer>) -> Self {
404        self.tool_layers.push(layer);
405        self
406    }
407
408    fn into_runtime(self) -> Result<Arc<dyn AgentRuntime>, AgentError> {
409        let llm = self.llm.ok_or_else(|| AgentError::Config("missing LLM port".to_string()))?;
410        let store =
411            self.store.ok_or_else(|| AgentError::Config("missing session store".to_string()))?;
412        let events =
413            self.events.ok_or_else(|| AgentError::Config("missing event sink".to_string()))?;
414        let default_model = self
415            .default_model
416            .ok_or_else(|| AgentError::Config("missing default model".to_string()))?;
417        let tool_policy: Arc<dyn ToolPolicyPort> = self
418            .tool_policy
419            .unwrap_or_else(|| Arc::new(DefaultToolPolicyPort) as Arc<dyn ToolPolicyPort>);
420        let approval: Arc<dyn ApprovalPort> = self
421            .approval
422            .unwrap_or_else(|| Arc::new(AllowAllApprovalPort) as Arc<dyn ApprovalPort>);
423        let checkpoint_store: Arc<dyn TurnCheckpointStorePort> =
424            self.checkpoint_store.unwrap_or_else(|| {
425                Arc::new(NoOpCheckpointStorePort) as Arc<dyn TurnCheckpointStorePort>
426            });
427        let artifact_store: Arc<dyn ArtifactStorePort> = self
428            .artifact_store
429            .unwrap_or_else(|| Arc::new(NoOpArtifactStorePort) as Arc<dyn ArtifactStorePort>);
430        let cost_meter: Arc<dyn CostMeterPort> = self
431            .cost_meter
432            .unwrap_or_else(|| Arc::new(NoOpCostMeterPort) as Arc<dyn CostMeterPort>);
433        let context_compactor: Arc<dyn ContextCompactorPort> =
434            self.context_compactor.unwrap_or_else(|| {
435                Arc::new(crate::prompt::WindowContextCompactor::default())
436                    as Arc<dyn ContextCompactorPort>
437            });
438        let journal: Arc<dyn ToolJournalPort> = self
439            .journal
440            .unwrap_or_else(|| Arc::new(NoOpToolJournalPort) as Arc<dyn ToolJournalPort>);
441
442        let mut tools: Arc<dyn ToolPort> =
443            self.tools.unwrap_or_else(|| Arc::new(NoOpToolPort) as Arc<dyn ToolPort>);
444        for layer in self.tool_layers {
445            tools = layer.wrap(tools);
446        }
447
448        let rt = DefaultAgentRuntime {
449            llm,
450            tools,
451            store,
452            events,
453            default_model,
454            policy: self.policy,
455            tool_policy,
456            approval,
457            dispatch_mode: self.dispatch_mode,
458            checkpoint_store,
459            artifact_store,
460            cost_meter,
461            context_compactor,
462            journal,
463        };
464        Ok(Arc::new(rt))
465    }
466}
467
468impl AgentBootstrap for RuntimeBuilder {
469    fn build(self) -> Result<Arc<dyn AgentRuntime>, AgentError>
470    where
471        Self: Sized,
472    {
473        self.into_runtime()
474    }
475}
476
477// ── Runtime Trait ────────────────────────────────────────────────────
478
479/// The primary API for running agent turns.
480#[async_trait::async_trait]
481pub trait AgentRuntime: Send + Sync {
482    /// Execute a single agent turn (blocking until complete).
483    async fn run(&self, req: AgentRequest) -> Result<AgentRunResult, AgentError>;
484
485    /// Execute a single agent turn with streaming events.
486    async fn run_stream(&self, req: AgentRequest) -> Result<AgentEventStream, AgentError>;
487
488    /// Check runtime health.
489    async fn health(&self) -> RuntimeHealth;
490}
491
492// ── Default Implementation ───────────────────────────────────────────
493
494/// Default runtime that composes the 4 port traits via `Arc<dyn ...>`.
495pub struct DefaultAgentRuntime {
496    pub llm: Arc<dyn LlmPort>,
497    pub tools: Arc<dyn ToolPort>,
498    pub store: Arc<dyn SessionStore>,
499    pub events: Arc<dyn EventSink>,
500    pub default_model: String,
501    pub policy: TurnPolicy,
502    pub tool_policy: Arc<dyn ToolPolicyPort>,
503    pub approval: Arc<dyn ApprovalPort>,
504    pub dispatch_mode: DispatchMode,
505    pub checkpoint_store: Arc<dyn TurnCheckpointStorePort>,
506    pub artifact_store: Arc<dyn ArtifactStorePort>,
507    pub cost_meter: Arc<dyn CostMeterPort>,
508    pub context_compactor: Arc<dyn ContextCompactorPort>,
509    pub journal: Arc<dyn ToolJournalPort>,
510}
511
512impl std::fmt::Debug for DefaultAgentRuntime {
513    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
514        f.debug_struct("DefaultAgentRuntime").finish_non_exhaustive()
515    }
516}
517
518#[async_trait::async_trait]
519impl AgentRuntime for DefaultAgentRuntime {
520    async fn run(&self, req: AgentRequest) -> Result<AgentRunResult, AgentError> {
521        scheduler::run_turn_with_extensions(
522            self.llm.as_ref(),
523            self.tools.as_ref(),
524            self.store.as_ref(),
525            self.events.as_ref(),
526            req,
527            &self.policy,
528            &self.default_model,
529            self.tool_policy.as_ref(),
530            self.approval.as_ref(),
531            self.dispatch_mode,
532            self.checkpoint_store.as_ref(),
533            self.artifact_store.as_ref(),
534            self.cost_meter.as_ref(),
535            self.context_compactor.as_ref(),
536            self.journal.as_ref(),
537        )
538        .await
539    }
540
541    async fn run_stream(&self, req: AgentRequest) -> Result<AgentEventStream, AgentError> {
542        scheduler::run_turn_stream_with_controls(
543            self.llm.clone(),
544            self.tools.clone(),
545            self.store.clone(),
546            self.events.clone(),
547            req,
548            self.policy.clone(),
549            self.default_model.clone(),
550            self.tool_policy.clone(),
551            self.approval.clone(),
552            self.dispatch_mode,
553            self.checkpoint_store.clone(),
554            self.artifact_store.clone(),
555            self.cost_meter.clone(),
556            self.context_compactor.clone(),
557            self.journal.clone(),
558        )
559        .await
560    }
561
562    async fn health(&self) -> RuntimeHealth {
563        RuntimeHealth { status: HealthStatus::Healthy, llm_ready: true, mcp_pool_ready: true }
564    }
565}
566
567// ── Tests ────────────────────────────────────────────────────────────
568
569#[cfg(test)]
570mod tests {
571    use std::sync::{
572        Arc, Mutex,
573        atomic::{AtomicUsize, Ordering},
574    };
575
576    use bob_core::{
577        error::{LlmError, StoreError, ToolError},
578        ports::ContextCompactorPort,
579        types::*,
580    };
581
582    use super::*;
583
584    // Minimal mock implementations for testing the runtime wiring.
585
586    struct StubLlm;
587
588    #[async_trait::async_trait]
589    impl LlmPort for StubLlm {
590        async fn complete(&self, _req: LlmRequest) -> Result<LlmResponse, LlmError> {
591            Ok(LlmResponse {
592                content: r#"{"type": "final", "content": "stub response"}"#.into(),
593                usage: TokenUsage::default(),
594                finish_reason: FinishReason::Stop,
595                tool_calls: Vec::new(),
596            })
597        }
598
599        async fn complete_stream(&self, _req: LlmRequest) -> Result<LlmStream, LlmError> {
600            Err(LlmError::Provider("not implemented".into()))
601        }
602    }
603
604    struct StubTools;
605
606    #[async_trait::async_trait]
607    impl ToolPort for StubTools {
608        async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError> {
609            Ok(vec![])
610        }
611
612        async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError> {
613            Ok(ToolResult { name: call.name, output: serde_json::json!(null), is_error: false })
614        }
615    }
616
617    struct StubStore;
618
619    #[async_trait::async_trait]
620    impl SessionStore for StubStore {
621        async fn load(&self, _id: &SessionId) -> Result<Option<SessionState>, StoreError> {
622            Ok(None)
623        }
624
625        async fn save(&self, _id: &SessionId, _state: &SessionState) -> Result<(), StoreError> {
626            Ok(())
627        }
628    }
629
630    struct StubSink {
631        count: Mutex<usize>,
632    }
633
634    impl EventSink for StubSink {
635        fn emit(&self, _event: AgentEvent) {
636            let mut count = self.count.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
637            *count += 1;
638        }
639    }
640
641    #[derive(Default)]
642    struct RecordingLlm {
643        requests: Mutex<Vec<LlmRequest>>,
644    }
645
646    #[async_trait::async_trait]
647    impl LlmPort for RecordingLlm {
648        async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
649            let mut requests =
650                self.requests.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
651            requests.push(req);
652            Ok(LlmResponse {
653                content: r#"{"type": "final", "content": "recorded"}"#.into(),
654                usage: TokenUsage::default(),
655                finish_reason: FinishReason::Stop,
656                tool_calls: Vec::new(),
657            })
658        }
659
660        async fn complete_stream(&self, _req: LlmRequest) -> Result<LlmStream, LlmError> {
661            Err(LlmError::Provider("not implemented".into()))
662        }
663    }
664
665    struct SessionFixtureStore {
666        state: SessionState,
667    }
668
669    #[async_trait::async_trait]
670    impl SessionStore for SessionFixtureStore {
671        async fn load(&self, _id: &SessionId) -> Result<Option<SessionState>, StoreError> {
672            Ok(Some(self.state.clone()))
673        }
674
675        async fn save(&self, _id: &SessionId, _state: &SessionState) -> Result<(), StoreError> {
676            Ok(())
677        }
678    }
679
680    struct OverrideCompactor {
681        invocations: AtomicUsize,
682        compacted: Vec<Message>,
683    }
684
685    #[async_trait::async_trait]
686    impl ContextCompactorPort for OverrideCompactor {
687        async fn compact(&self, _session: &SessionState) -> Vec<Message> {
688            self.invocations.fetch_add(1, Ordering::SeqCst);
689            self.compacted.clone()
690        }
691    }
692
693    #[tokio::test]
694    async fn default_runtime_run() {
695        let rt: Arc<dyn AgentRuntime> = Arc::new(DefaultAgentRuntime {
696            llm: Arc::new(StubLlm),
697            tools: Arc::new(StubTools),
698            store: Arc::new(StubStore),
699            events: Arc::new(StubSink { count: Mutex::new(0) }),
700            default_model: "test-model".into(),
701            policy: TurnPolicy::default(),
702            tool_policy: Arc::new(DefaultToolPolicyPort),
703            approval: Arc::new(AllowAllApprovalPort),
704            dispatch_mode: DispatchMode::PromptGuided,
705            checkpoint_store: Arc::new(NoOpCheckpointStorePort),
706            artifact_store: Arc::new(NoOpArtifactStorePort),
707            cost_meter: Arc::new(NoOpCostMeterPort),
708            context_compactor: Arc::new(crate::prompt::WindowContextCompactor::default()),
709            journal: Arc::new(NoOpToolJournalPort),
710        });
711
712        let req = AgentRequest {
713            input: "hello".into(),
714            session_id: "test".into(),
715            model: None,
716            context: RequestContext::default(),
717            cancel_token: None,
718            output_schema: None,
719            max_output_retries: 0,
720        };
721
722        let result = rt.run(req).await;
723        assert!(
724            matches!(result, Ok(AgentRunResult::Finished(_))),
725            "run should finish successfully"
726        );
727        if let Ok(AgentRunResult::Finished(resp)) = result {
728            assert_eq!(resp.finish_reason, FinishReason::Stop);
729            assert_eq!(resp.content, "stub response");
730        }
731    }
732
733    #[tokio::test]
734    async fn default_runtime_health() {
735        let rt = DefaultAgentRuntime {
736            llm: Arc::new(StubLlm),
737            tools: Arc::new(StubTools),
738            store: Arc::new(StubStore),
739            events: Arc::new(StubSink { count: Mutex::new(0) }),
740            default_model: "test-model".into(),
741            policy: TurnPolicy::default(),
742            tool_policy: Arc::new(DefaultToolPolicyPort),
743            approval: Arc::new(AllowAllApprovalPort),
744            dispatch_mode: DispatchMode::PromptGuided,
745            checkpoint_store: Arc::new(NoOpCheckpointStorePort),
746            artifact_store: Arc::new(NoOpArtifactStorePort),
747            cost_meter: Arc::new(NoOpCostMeterPort),
748            context_compactor: Arc::new(crate::prompt::WindowContextCompactor::default()),
749            journal: Arc::new(NoOpToolJournalPort),
750        };
751
752        let health = rt.health().await;
753        assert_eq!(health.status, HealthStatus::Healthy);
754    }
755
756    #[tokio::test]
757    async fn runtime_builder_requires_core_dependencies() {
758        let result = RuntimeBuilder::new().build();
759        assert!(
760            matches!(result, Err(AgentError::Config(msg)) if msg.contains("missing LLM")),
761            "missing llm should return config error"
762        );
763    }
764
765    #[tokio::test]
766    async fn runtime_builder_uses_custom_context_compactor() {
767        let llm = Arc::new(RecordingLlm::default());
768        let compactor = Arc::new(OverrideCompactor {
769            invocations: AtomicUsize::new(0),
770            compacted: vec![Message::text(Role::Assistant, "compacted-history")],
771        });
772
773        let runtime = RuntimeBuilder::new()
774            .with_llm(llm.clone())
775            .with_tools(Arc::new(StubTools))
776            .with_store(Arc::new(SessionFixtureStore {
777                state: SessionState {
778                    messages: vec![
779                        Message::text(Role::User, "original-user"),
780                        Message::text(Role::Assistant, "original-assistant"),
781                    ],
782                    ..Default::default()
783                },
784            }))
785            .with_events(Arc::new(StubSink { count: Mutex::new(0) }))
786            .with_default_model("test-model")
787            .with_context_compactor(compactor.clone())
788            .build()
789            .expect("runtime should build");
790
791        let req = AgentRequest {
792            input: "hello".into(),
793            session_id: "test".into(),
794            model: None,
795            context: RequestContext::default(),
796            cancel_token: None,
797            output_schema: None,
798            max_output_retries: 0,
799        };
800
801        let result = runtime.run(req).await;
802        assert!(matches!(result, Ok(AgentRunResult::Finished(_))), "unexpected result: {result:?}");
803        assert_eq!(compactor.invocations.load(Ordering::SeqCst), 1);
804
805        let requests = llm.requests.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
806        let request = requests.last().expect("llm should receive one request");
807        assert!(request.messages.iter().any(|message| message.content == "compacted-history"));
808        assert!(!request.messages.iter().any(|message| message.content == "original-assistant"));
809    }
810}