1#![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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
128pub enum DispatchMode {
129 PromptGuided,
131 #[default]
133 NativePreferred,
134}
135
136#[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#[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#[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#[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#[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#[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
253pub trait AgentBootstrap: Send {
257 fn build(self) -> Result<Arc<dyn AgentRuntime>, AgentError>
259 where
260 Self: Sized;
261}
262
263#[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#[async_trait::async_trait]
482pub trait AgentRuntime: Send + Sync {
483 async fn run(&self, req: AgentRequest) -> Result<AgentRunResult, AgentError>;
485
486 async fn run_stream(&self, req: AgentRequest) -> Result<AgentEventStream, AgentError>;
488
489 async fn health(&self) -> RuntimeHealth;
491}
492
493pub 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#[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 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}