1#![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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
126pub enum DispatchMode {
127 PromptGuided,
129 #[default]
131 NativePreferred,
132}
133
134#[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#[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#[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#[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#[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#[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
251pub trait AgentBootstrap: Send {
255 fn build(self) -> Result<Arc<dyn AgentRuntime>, AgentError>
257 where
258 Self: Sized;
259}
260
261#[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#[async_trait::async_trait]
480pub trait AgentRuntime: Send + Sync {
481 async fn run(&self, req: AgentRequest) -> Result<AgentRunResult, AgentError>;
483
484 async fn run_stream(&self, req: AgentRequest) -> Result<AgentEventStream, AgentError>;
486
487 async fn health(&self) -> RuntimeHealth;
489}
490
491pub 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#[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 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}