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