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