1use crate::agent::FinishReason;
25use crate::compact::Compactor;
26use crate::event::AgentEvent;
27use crate::hooks::HookRegistry;
28use crate::llm::{ChatProvider, TokenUsage, ToolSpec};
29use crate::message::Message;
30use crate::permissions::PermissionMode;
31use crate::storage::{NoopSessionStore, SessionStore, StorageBackend};
32use crate::tool_set_provider::ToolSetProvider;
33use crate::tools::PermissionHook;
34use crate::tools::ToolRegistry;
35use std::sync::atomic::AtomicBool;
36use std::sync::Arc;
37
38pub struct TurnContext {
59 pub messages: Arc<Vec<Message>>,
65
66 pub step_events_tx: Option<tokio::sync::mpsc::UnboundedSender<AgentEvent>>,
72
73 pub tool_specs: Vec<ToolSpec>,
75
76 pub streaming: bool,
78
79 pub permission_hook: Option<Arc<dyn PermissionHook>>,
81
82 pub exploring_plan_mode: Arc<AtomicBool>,
85
86 pub permission_mode: PermissionMode,
89
90 pub mailbox: Option<crate::tools::send_message::WorkerMailbox>,
96
97 pub turn: u32,
101
102 pub prompt_segments: Option<crate::system_prompt::PromptSegments>,
107
108 pub wall_timeout_secs: u64,
112}
113
114#[derive(Debug)]
123pub struct TurnOutcome {
124 pub new_messages: Vec<Message>,
127
128 pub final_text: Option<String>,
130
131 pub finish_reason: FinishReason,
133
134 pub usage: TokenUsage,
136
137 pub last_prompt_tokens: u32,
140
141 pub llm_latency_ms: u64,
143
144 pub steps: usize,
146
147 pub tool_audits: std::collections::HashMap<crate::tools::AuditKey, crate::tools::AuditMeta>,
151
152 pub turn: u32,
155}
156
157#[derive(Clone)]
167pub struct AgentKernel {
168 pub(crate) llm: Arc<dyn ChatProvider>,
170 pub(crate) tools: ToolRegistry,
172 pub(crate) max_steps: usize,
174 pub(crate) max_transcript_chars: Option<usize>,
176 pub(crate) compactor: Option<Compactor>,
178 pub(crate) microcompactor: Option<crate::compact::Microcompactor>,
180 pub(crate) hooks: HookRegistry,
182 pub(crate) shutdown_token: Option<tokio_util::sync::CancellationToken>,
187 pub(crate) storage: Arc<dyn StorageBackend>,
190 pub(crate) session_store: Arc<dyn SessionStore>,
193 pub(crate) stuck_window: usize,
195 pub(crate) stuck_error_rate: f64,
197 pub(crate) globs_skills: Vec<crate::skills::Skill>,
199}
200
201impl std::fmt::Debug for AgentKernel {
202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203 let tools_count = self.tools.names().len();
204 let hooks_count = self.hooks.len();
205 f.debug_struct("AgentKernel")
206 .field("llm", &"<ChatProvider>")
207 .field("tools_count", &tools_count)
208 .field("max_steps", &self.max_steps)
209 .field("max_transcript_chars", &self.max_transcript_chars)
210 .field("compactor", &self.compactor)
211 .field("hooks_count", &hooks_count)
212 .field("storage", &"<StorageBackend>")
213 .field("session_store", &"<SessionStore>")
214 .finish()
215 }
216}
217
218impl AgentKernel {
219 #[cfg_attr(test, mutants::skip)]
221 pub fn builder() -> AgentKernelBuilder {
222 AgentKernelBuilder::default()
223 }
224
225 pub fn llm(&self) -> &Arc<dyn ChatProvider> {
227 &self.llm
228 }
229
230 pub fn set_llm(&mut self, llm: Arc<dyn ChatProvider>) {
238 self.llm = llm;
239 }
240
241 pub fn tools(&self) -> &ToolRegistry {
243 &self.tools
244 }
245
246 pub fn tools_mut(&mut self) -> &mut ToolRegistry {
252 &mut self.tools
253 }
254
255 pub fn shutdown_token(&self) -> Option<&tokio_util::sync::CancellationToken> {
260 self.shutdown_token.as_ref()
261 }
262
263 pub fn storage(&self) -> &Arc<dyn StorageBackend> {
265 &self.storage
266 }
267
268 pub fn session_store(&self) -> &Arc<dyn SessionStore> {
270 &self.session_store
271 }
272
273 pub fn hooks(&self) -> &HookRegistry {
277 &self.hooks
278 }
279
280 pub fn with_tools(&self, tools: ToolRegistry) -> Self {
283 let mut clone = self.clone();
284 clone.tools = tools;
285 clone
286 }
287
288 pub async fn run(&self, ctx: TurnContext) -> crate::error::Result<TurnOutcome> {
298 let input_len = ctx.messages.len();
299
300 let core = {
301 use crate::run_core::{RunCore, StaticBreakdownCache};
302 let static_breakdown = match ctx.prompt_segments.as_ref() {
308 Some(segments) => {
309 StaticBreakdownCache::build(segments, &self.tools.specs(), &self.tools)
310 }
311 None => StaticBreakdownCache::default(),
312 };
313 RunCore {
314 messages: ctx.messages,
315 llm: self.llm.clone(),
316 tools: Arc::new(self.tools.clone()),
317 max_steps: self.max_steps,
318 max_transcript_chars: self.max_transcript_chars,
319 events: ctx.step_events_tx,
320 streaming: ctx.streaming,
321 compactor: self.compactor.clone(),
322 microcompactor: self.microcompactor.clone(),
323 permission_hook: ctx.permission_hook,
324 hooks: &self.hooks,
325 total_llm_latency_ms: 0,
326 exploring_plan_mode: ctx.exploring_plan_mode,
327 shutdown_token: self.shutdown_token.clone(),
328 mailbox: ctx.mailbox,
329 stuck_window: self.stuck_window,
330 stuck_error_rate: self.stuck_error_rate,
331 turn: ctx.turn,
332 globs_skills: self.globs_skills.clone(),
333 prompt_segments: ctx.prompt_segments,
334 static_breakdown,
335 last_prompt_tokens: 0,
336 consecutive_compact_failures: 0,
337 wall_timeout_secs: ctx.wall_timeout_secs,
338 wall_start: if ctx.wall_timeout_secs > 0 {
339 Some(std::time::Instant::now())
340 } else {
341 None
342 },
343 }
344 };
345
346 let inner = core.run_inner().await?;
347
348 let mut new_messages = turn_delta_messages(&inner.messages, input_len);
355 if !inner.messages.is_empty() && inner.messages[0].is_compaction_summary {
356 new_messages.insert(0, inner.messages[0].clone());
357 }
358
359 Ok(TurnOutcome {
360 new_messages,
361 final_text: inner.final_message,
362 finish_reason: inner.finish_reason,
363 usage: inner.total_usage,
364 last_prompt_tokens: inner.last_prompt_tokens,
365 llm_latency_ms: inner.total_llm_latency_ms,
366 steps: inner.steps,
367 tool_audits: inner.tool_audits,
368 turn: ctx.turn,
369 })
370 }
371}
372
373#[cfg_attr(test, mutants::skip)]
376fn turn_delta_messages(
377 inner: &[crate::message::Message],
378 input_len: usize,
379) -> Vec<crate::message::Message> {
380 if inner.len() > input_len {
381 inner[input_len..].to_vec()
382 } else {
383 Vec::new()
384 }
385}
386
387#[derive(Default)]
393pub struct AgentKernelBuilder {
394 llm: Option<Arc<dyn ChatProvider>>,
395 tools: Option<ToolRegistry>,
396 max_steps: Option<usize>,
397 max_transcript_chars: Option<usize>,
398 compactor: Option<Compactor>,
399 microcompactor: Option<crate::compact::Microcompactor>,
400 hooks: Option<HookRegistry>,
401 shutdown_token: Option<tokio_util::sync::CancellationToken>,
402 storage: Option<Arc<dyn StorageBackend>>,
405 session_store: Option<Arc<dyn SessionStore>>,
408 tool_set_provider: Option<Arc<dyn ToolSetProvider>>,
411 stuck_window: Option<usize>,
413 stuck_error_rate: Option<f64>,
415 globs_skills: Vec<crate::skills::Skill>,
417}
418
419impl std::fmt::Debug for AgentKernelBuilder {
420 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
421 let tools_desc = self.tools.as_ref().map(|t| t.names().len());
422 let hooks_desc = self.hooks.as_ref().map(|h| h.len());
423 f.debug_struct("AgentKernelBuilder")
424 .field("llm", &self.llm.as_ref().map(|_| "<ChatProvider>"))
425 .field("tools", &tools_desc)
426 .field("max_steps", &self.max_steps)
427 .field("max_transcript_chars", &self.max_transcript_chars)
428 .field("compactor", &self.compactor)
429 .field("hooks", &hooks_desc)
430 .field(
431 "storage",
432 &self.storage.as_ref().map(|_| "<StorageBackend>"),
433 )
434 .field(
435 "session_store",
436 &self.session_store.as_ref().map(|_| "<SessionStore>"),
437 )
438 .field(
439 "tool_set_provider",
440 &self.tool_set_provider.as_ref().map(|_| "<ToolSetProvider>"),
441 )
442 .finish()
443 }
444}
445
446impl AgentKernelBuilder {
447 pub fn llm(mut self, llm: Arc<dyn ChatProvider>) -> Self {
449 self.llm = Some(llm);
450 self
451 }
452
453 pub fn tools(mut self, tools: ToolRegistry) -> Self {
455 self.tools = Some(tools);
456 self
457 }
458
459 pub fn max_steps(mut self, n: usize) -> Self {
461 self.max_steps = Some(n);
462 self
463 }
464
465 pub fn max_transcript_chars(mut self, n: usize) -> Self {
467 self.max_transcript_chars = Some(n);
468 self
469 }
470
471 pub fn compactor(mut self, compactor: Compactor) -> Self {
473 self.compactor = Some(compactor);
474 self
475 }
476
477 pub fn microcompactor(mut self, microcompactor: crate::compact::Microcompactor) -> Self {
479 self.microcompactor = Some(microcompactor);
480 self
481 }
482
483 pub fn hooks(mut self, hooks: HookRegistry) -> Self {
485 self.hooks = Some(hooks);
486 self
487 }
488
489 pub fn shutdown_token(mut self, token: tokio_util::sync::CancellationToken) -> Self {
494 self.shutdown_token = Some(token);
495 self
496 }
497
498 pub fn with_storage(mut self, backend: Arc<dyn StorageBackend>) -> Self {
501 self.storage = Some(backend);
502 self
503 }
504
505 pub fn with_session_store(mut self, store: Arc<dyn SessionStore>) -> Self {
508 self.session_store = Some(store);
509 self
510 }
511
512 pub fn with_tool_set_provider(mut self, provider: Arc<dyn ToolSetProvider>) -> Self {
516 self.tool_set_provider = Some(provider);
517 self
518 }
519
520 pub fn stuck_window(mut self, n: usize) -> Self {
522 self.stuck_window = Some(n);
523 self
524 }
525
526 pub fn stuck_error_rate(mut self, rate: f64) -> Self {
528 self.stuck_error_rate = Some(rate);
529 self
530 }
531
532 pub fn build(self) -> crate::error::Result<AgentKernel> {
534 let llm = self.llm.ok_or_else(|| crate::error::Error::Config {
535 message: "llm provider is required".into(),
536 })?;
537 let tools = if let Some(registry) = self.tools {
539 registry
540 } else if let Some(ref provider) = self.tool_set_provider {
541 provider.build_registry()
542 } else {
543 ToolRegistry::local()
544 };
545 let max_steps = self.max_steps.unwrap_or(0);
546 let hooks = self.hooks.unwrap_or_default();
547 let storage: Arc<dyn StorageBackend> = self.storage.unwrap_or_else(|| {
549 Arc::new(crate::storage::local::LocalStorageBackend::new(
550 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
551 ))
552 });
553 let session_store: Arc<dyn SessionStore> = self
554 .session_store
555 .unwrap_or_else(|| Arc::new(NoopSessionStore));
556 Ok(AgentKernel {
557 llm,
558 tools,
559 max_steps,
560 max_transcript_chars: self.max_transcript_chars,
561 compactor: self.compactor,
562 microcompactor: self.microcompactor,
563 hooks,
564 shutdown_token: self.shutdown_token,
565 storage,
566 session_store,
567 stuck_window: self.stuck_window.unwrap_or(10),
568 stuck_error_rate: self.stuck_error_rate.unwrap_or(0.8),
569 globs_skills: self.globs_skills,
570 })
571 }
572
573 pub fn skills(mut self, skills: Vec<crate::skills::Skill>) -> Self {
575 self.globs_skills = skills;
576 self
577 }
578}
579
580#[cfg(test)]
585mod tests {
586 use super::*;
587 use crate::llm::MockProvider;
588
589 #[test]
592 fn kernel_builder_requires_llm() {
593 let result = AgentKernel::builder().build();
594 assert!(result.is_err());
595 match result {
596 Err(e) => assert!(e.to_string().contains("llm provider is required")),
597 Ok(_) => panic!("expected Err"),
598 }
599 }
600
601 #[test]
602 fn kernel_builder_happy_path() {
603 let mock = MockProvider::default();
604 let tools = ToolRegistry::local();
605 let kernel = AgentKernel::builder()
606 .llm(Arc::new(mock))
607 .tools(tools)
608 .max_steps(16)
609 .build()
610 .expect("build should succeed");
611 assert_eq!(kernel.max_steps, 16);
612 }
613
614 #[test]
615 fn kernel_builder_default_max_steps() {
616 let mock = MockProvider::default();
617 let tools = ToolRegistry::local();
618 let kernel = AgentKernel::builder()
619 .llm(Arc::new(mock))
620 .tools(tools)
621 .build()
622 .expect("build should succeed");
623 assert_eq!(kernel.max_steps, 0);
624 }
625
626 #[test]
629 fn kernel_clone_is_independent() {
630 let mock = MockProvider::default();
631 let tools1 = ToolRegistry::local();
632 let kernel = AgentKernel::builder()
633 .llm(Arc::new(mock))
634 .tools(tools1)
635 .build()
636 .expect("build should succeed");
637
638 let mut cloned = kernel.clone();
639 let new_tools = ToolRegistry::local();
641 cloned.tools = new_tools;
642
643 assert!(!Arc::ptr_eq(
647 kernel.tools().transport(),
648 cloned.tools().transport()
649 ));
650 }
651
652 #[test]
653 fn kernel_with_tools_preserves_llm() {
654 let mock = MockProvider::default();
655 let mock_arc = Arc::new(mock);
656 let tools1 = ToolRegistry::local();
657 let kernel = AgentKernel::builder()
658 .llm(mock_arc.clone())
659 .tools(tools1)
660 .build()
661 .expect("build should succeed");
662
663 let tools2 = ToolRegistry::local();
664 let new_kernel = kernel.with_tools(tools2);
665
666 assert!(Arc::ptr_eq(&kernel.llm, &new_kernel.llm));
668 assert_eq!(new_kernel.max_steps, kernel.max_steps);
670 }
671
672 fn make_minimal_ctx(messages: Vec<Message>) -> TurnContext {
675 use std::sync::atomic::AtomicBool;
676 TurnContext {
677 messages: Arc::new(messages),
678 step_events_tx: None,
679 tool_specs: vec![],
680 streaming: false,
681 permission_hook: None,
682 exploring_plan_mode: Arc::new(AtomicBool::new(false)),
683 permission_mode: crate::permissions::PermissionMode::Default,
684 mailbox: None,
685 turn: 0,
686 prompt_segments: None,
687 wall_timeout_secs: 0,
688 }
689 }
690
691 #[tokio::test]
698 async fn kernel_run_new_messages_contains_reply() {
699 use crate::llm::Completion;
700 let provider = Arc::new(MockProvider::new(vec![Completion {
701 content: "done".to_string(),
702 tool_calls: vec![],
703 finish_reason: Some("stop".to_string()),
704 usage: None,
705 reasoning_content: None,
706 }]));
707 let kernel = AgentKernel::builder()
708 .llm(provider)
709 .max_steps(1)
710 .build()
711 .expect("build");
712
713 let ctx = make_minimal_ctx(vec![Message::user("hello".to_string())]);
714 let outcome = kernel.run(ctx).await.expect("run");
715
716 assert_eq!(
717 outcome.new_messages.len(),
718 1,
719 "new_messages must contain exactly the assistant reply; got {:?}",
720 outcome.new_messages
721 );
722 assert_eq!(outcome.new_messages[0].content, "done");
723 }
724
725 #[tokio::test]
732 async fn kernel_run_does_not_prepend_input_to_new_messages() {
733 use crate::llm::Completion;
734 let provider = Arc::new(MockProvider::new(vec![Completion {
735 content: "answer".to_string(),
736 tool_calls: vec![],
737 finish_reason: Some("stop".to_string()),
738 usage: None,
739 reasoning_content: None,
740 }]));
741 let kernel = AgentKernel::builder()
742 .llm(provider)
743 .max_steps(1)
744 .build()
745 .expect("build");
746
747 let input = Message::user("question".to_string());
748 let ctx = make_minimal_ctx(vec![input.clone()]);
749 let outcome = kernel.run(ctx).await.expect("run");
750
751 assert_eq!(outcome.new_messages.len(), 1, "only reply expected");
753 assert_eq!(
754 outcome.new_messages[0].content, "answer",
755 "first new message must be the reply, not the input"
756 );
757 assert!(
758 outcome.new_messages[0].content != "question",
759 "input must not appear in new_messages"
760 );
761 }
762
763 #[tokio::test]
766 async fn kernel_run_does_not_prepend_non_summary_first_message() {
767 use crate::llm::Completion;
768 let provider = Arc::new(MockProvider::new(vec![Completion {
769 content: "reply".to_string(),
770 tool_calls: vec![],
771 finish_reason: Some("stop".to_string()),
772 usage: None,
773 reasoning_content: None,
774 }]));
775 let kernel = AgentKernel::builder()
776 .llm(provider)
777 .max_steps(1)
778 .build()
779 .expect("build");
780
781 let system = Message::system("sys".to_string());
782 assert!(
783 !system.is_compaction_summary,
784 "fixture must not be a compaction summary"
785 );
786 let ctx = make_minimal_ctx(vec![system, Message::user("q".to_string())]);
787 let outcome = kernel.run(ctx).await.expect("run");
788 assert_eq!(outcome.new_messages.len(), 1);
789 assert_eq!(outcome.new_messages[0].content, "reply");
790 assert!(
791 outcome.new_messages.iter().all(|m| m.content != "sys"),
792 "non-summary first message must not be prepended; got {:?}",
793 outcome.new_messages
794 );
795 }
796
797 #[tokio::test]
798 async fn kernel_run_prepends_compaction_summary_to_new_messages() {
799 use crate::compact::Compactor;
800 use crate::llm::Completion;
801 let messages = vec![
802 Message::system("sys".to_string()),
803 Message::user("u1".to_string()),
804 Message::assistant("a1".to_string()),
805 Message::user("u2".to_string()),
806 Message::assistant("a2".to_string()),
807 Message::user("u3".to_string()),
808 ];
809 let provider = Arc::new(MockProvider::new(vec![
810 Completion {
811 content: "compact summary".to_string(),
812 tool_calls: vec![],
813 finish_reason: Some("stop".to_string()),
814 usage: None,
815 reasoning_content: None,
816 },
817 Completion {
818 content: "final".to_string(),
819 tool_calls: vec![],
820 finish_reason: Some("stop".to_string()),
821 usage: None,
822 reasoning_content: None,
823 },
824 ]));
825 let kernel = AgentKernel::builder()
826 .llm(provider)
827 .compactor(Compactor::new(0).keep_recent_n(2))
828 .max_steps(1)
829 .build()
830 .expect("build");
831
832 let ctx = make_minimal_ctx(messages);
833 let outcome = kernel.run(ctx).await.expect("run");
834
835 assert!(
836 !outcome.new_messages.is_empty(),
837 "expected new messages after compaction + reply"
838 );
839 assert!(
840 outcome.new_messages[0].is_compaction_summary,
841 "compaction summary must be prepended to new_messages; got {:?}",
842 outcome.new_messages
843 );
844 }
845
846 #[test]
847 fn kernel_builder_max_transcript_chars_stored_and_chains() {
848 let mock = Arc::new(MockProvider::default());
849 let kernel = AgentKernel::builder()
850 .llm(mock)
851 .max_transcript_chars(12_345)
852 .max_steps(3)
853 .build()
854 .expect("build");
855 assert_eq!(
856 kernel.max_transcript_chars,
857 Some(12_345),
858 "max_transcript_chars must survive builder chaining"
859 );
860 assert_eq!(
861 kernel.max_steps, 3,
862 "builder chain must not reset max_steps"
863 );
864 }
865
866 #[test]
869 fn agent_kernel_builder_debug_contains_field_names() {
870 let builder = AgentKernel::builder().max_steps(42);
874 let dbg = format!("{:?}", builder);
875 assert!(
876 dbg.contains("AgentKernelBuilder"),
877 "Debug output must contain struct name; got: {dbg}"
878 );
879 assert!(
880 dbg.contains("max_steps"),
881 "Debug output must contain max_steps field; got: {dbg}"
882 );
883 }
884
885 #[test]
886 fn kernel_builder_stuck_window_and_error_rate() {
887 let mock = MockProvider::default();
889 let kernel_defaults = AgentKernel::builder().llm(Arc::new(mock)).build().unwrap();
890 assert_eq!(
891 kernel_defaults.stuck_window, 10,
892 "default stuck_window must be 10"
893 );
894 assert!(
895 (kernel_defaults.stuck_error_rate - 0.8).abs() < 1e-10,
896 "default stuck_error_rate must be 0.8"
897 );
898
899 let mock2 = MockProvider::default();
900 let kernel_custom = AgentKernel::builder()
901 .llm(Arc::new(mock2))
902 .stuck_window(5)
903 .stuck_error_rate(0.5)
904 .build()
905 .unwrap();
906 assert_eq!(kernel_custom.stuck_window, 5);
907 assert!((kernel_custom.stuck_error_rate - 0.5).abs() < 1e-10);
908 }
909
910 #[test]
911 fn kernel_accessor_methods() {
912 let mock = Arc::new(MockProvider::default());
914 let kernel = AgentKernel::builder().llm(mock.clone()).build().unwrap();
915 assert!(
917 Arc::ptr_eq(&kernel.llm, kernel.llm()),
918 "llm() must return &self.llm"
919 );
920 let _ = kernel.tools();
922 let _ = kernel.hooks();
924 let _ = kernel.storage();
926 let _ = kernel.session_store();
928 assert!(
930 kernel.shutdown_token().is_none(),
931 "no token must be set by default"
932 );
933 }
934
935 #[test]
936 fn kernel_max_steps_zero_by_default() {
937 let mock = Arc::new(MockProvider::default());
939 let kernel = AgentKernel::builder().llm(mock).build().unwrap();
940 assert_eq!(
941 kernel.max_steps, 0,
942 "default max_steps must be 0 (unlimited)"
943 );
944 }
945
946 #[test]
947 fn kernel_max_steps_custom_value() {
948 let mock = Arc::new(MockProvider::default());
950 let kernel = AgentKernel::builder()
951 .llm(mock)
952 .max_steps(25)
953 .build()
954 .unwrap();
955 assert_eq!(kernel.max_steps, 25, "custom max_steps must be stored");
956 }
957
958 #[test]
959 fn with_tools_replaces_registry() {
960 use crate::tools::transport::LocalTransport;
962 let mock = Arc::new(MockProvider::default());
963 let kernel = AgentKernel::builder().llm(mock.clone()).build().unwrap();
964 let empty_reg = ToolRegistry::new(Arc::new(LocalTransport));
966 let replaced = kernel.with_tools(empty_reg);
967 assert_eq!(
970 replaced.tools().names().len(),
971 0,
972 "with_tools must swap in the empty registry"
973 );
974 }
975
976 #[test]
979 fn turn_outcome_default_values() {
980 let outcome = TurnOutcome {
981 new_messages: vec![],
982 final_text: None,
983 finish_reason: FinishReason::NoMoreToolCalls,
984 usage: TokenUsage::default(),
985 last_prompt_tokens: 0,
986 llm_latency_ms: 0,
987 steps: 0,
988 tool_audits: std::collections::HashMap::new(),
989 turn: 0,
990 };
991 assert!(outcome.new_messages.is_empty());
992 assert!(outcome.final_text.is_none());
993 assert_eq!(outcome.finish_reason, FinishReason::NoMoreToolCalls);
994 assert_eq!(outcome.usage, TokenUsage::default());
995 assert_eq!(outcome.llm_latency_ms, 0);
996 assert_eq!(outcome.steps, 0);
997 }
998}