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