1#![allow(unused_doc_comments)]
2
3const TTSR_INTERRUPT_TEMPLATE: &str = include_str!("../prompts/ttsr-interrupt.md");
16
17pub mod append_only;
19pub mod compaction;
21pub mod config;
23pub mod helpers;
25pub mod queues;
27pub mod retry;
29pub mod stream_outcome;
31pub mod streaming;
33pub mod todo_policy;
35pub mod tool_exec;
37pub mod ttsr;
39
40use crate::agent::ProviderResolver;
42use crate::compaction::{CompactedContext, CompactionEvent};
43use crate::events::AgentEvent;
44use crate::state::TokenSource;
45use crate::tools::todo::{MidRunNudgeState, StopReminderState, build_stop_reminder};
46use crate::{state::SharedState, tools::ToolContext, tools::ToolRegistry};
47use anyhow::{Error, Result};
48pub use config::{AfterToolCallHook, AgentLoopConfig, BeforeToolCallHook, ToolExecutionMode};
49use oxicode_ai::{
50 CompactionManager as OxCompactionManager, CompactionStrategy, ContentBlock, LlmCompactor,
51 Message, Provider, StopReason, TextContent, UserMessage,
52};
53use parking_lot::RwLock;
54use std::sync::Arc;
55use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
56use std::time::Instant;
57
58use self::helpers::{sanitize_orphaned_tool_results, should_stop_after_turn};
59use self::queues::{
60 clear_all_queues, clear_follow_up_queue, clear_steering_queue, drain_follow_up_queue,
61 drain_steering_queue, try_push_follow_up, try_push_steering,
62};
63use self::retry::{
64 auto_retry_attempt_method, cancel_auto_retry, handle_retryable_error, is_retryable_error,
65};
66use self::streaming::stream_assistant_response;
67use self::tool_exec::execute_tool_calls;
68
69pub use self::stream_outcome::StreamOutcome;
70type EmitFn = Arc<dyn Fn(AgentEvent) + Send + Sync>;
71
72pub struct AgentLoop {
74 provider: Arc<dyn Provider>,
75 config: AgentLoopConfig,
76 tools: Arc<ToolRegistry>,
77 state: SharedState,
78 compaction_manager: OxCompactionManager,
79 before_tool_call: Option<BeforeToolCallHook>,
80 after_tool_call: Option<AfterToolCallHook>,
81 steering_queue: RwLock<Vec<Message>>,
82 follow_up_queue: RwLock<Vec<Message>>,
83 session_id: Option<String>,
84 auto_retry_attempt: AtomicUsize,
85 auto_retry_cancel: AtomicBool,
86 auto_retry_notify: tokio::sync::Notify,
88 external_stop: Arc<AtomicBool>,
91 cancel_signal: Option<Arc<AtomicBool>>,
95 auto_retry_enabled_override: Option<Arc<AtomicBool>>,
99 auto_retry_cancel_signal: Option<Arc<AtomicBool>>,
101 auto_retry_notify_signal: Option<Arc<tokio::sync::Notify>>,
103 resolver: Arc<dyn ProviderResolver>,
105 steering_hook: Option<Arc<dyn Fn() -> Vec<Message> + Send + Sync>>,
108 follow_up_hook: Option<Arc<dyn Fn() -> Vec<Message> + Send + Sync>>,
110 ttsr_engine: Option<Arc<ttsr::TtsrEngine>>,
112 thinking_loop_detector:
117 parking_lot::Mutex<Option<oxicode_ai::utils::thinking_loop::ThinkingLoopDetector>>,
118 tool_call_loop_guard: parking_lot::Mutex<oxicode_ai::utils::tool_call_loop::ToolCallLoopGuard>,
123 soft_requirement_state: parking_lot::Mutex<crate::agent_loop::config::SoftRequirementState>,
125}
126
127impl AgentLoop {
128 pub fn new_with_resolver(
132 provider: Arc<dyn Provider>,
133 config: AgentLoopConfig,
134 tools: Arc<ToolRegistry>,
135 state: SharedState,
136 resolver: Arc<dyn ProviderResolver>,
137 ) -> Self {
138 let mut compaction_manager =
139 OxCompactionManager::new(config.compaction_strategy.clone(), config.context_window);
140
141 if let Some(compactor) = &config.compactor {
148 compaction_manager.set_compactor(Arc::clone(compactor));
149 } else if config.compaction_strategy != CompactionStrategy::Disabled {
150 let model = resolver.resolve_model(&config.model_id);
151 if let Some(model) = model {
152 let llm_compactor =
153 Arc::new(LlmCompactor::new(model.clone(), Arc::clone(&provider)));
154 compaction_manager.set_compactor(llm_compactor);
155 }
156 }
157
158 Self {
159 provider,
160 config: config.clone(),
161 tools,
162 state,
163 compaction_manager,
164 before_tool_call: None,
165 after_tool_call: None,
166 steering_queue: RwLock::new(Vec::new()),
167 follow_up_queue: RwLock::new(Vec::new()),
168 session_id: config.session_id.clone(),
169 auto_retry_attempt: AtomicUsize::new(0),
170 auto_retry_cancel: AtomicBool::new(false),
171 auto_retry_notify: tokio::sync::Notify::new(),
172 external_stop: Arc::new(AtomicBool::new(false)),
173 cancel_signal: None,
174 auto_retry_enabled_override: None,
175 auto_retry_cancel_signal: None,
176 auto_retry_notify_signal: None,
177 resolver,
178 steering_hook: None,
179 follow_up_hook: None,
180 ttsr_engine: config.ttsr_engine.clone(),
181 thinking_loop_detector: parking_lot::Mutex::new(if config.thinking_loop_detection {
182 Some(oxicode_ai::utils::thinking_loop::ThinkingLoopDetector::new())
183 } else {
184 None
185 }),
186 tool_call_loop_guard: parking_lot::Mutex::new(
187 oxicode_ai::utils::tool_call_loop::ToolCallLoopGuard::new(
188 config.tool_call_loop_guard.clone(),
189 ),
190 ),
191 soft_requirement_state: parking_lot::Mutex::new(
192 crate::agent_loop::config::SoftRequirementState::default(),
193 ),
194 }
195 }
196
197 pub fn new(
199 provider: Arc<dyn Provider>,
200 config: AgentLoopConfig,
201 tools: Arc<ToolRegistry>,
202 state: SharedState,
203 ) -> Self {
204 use crate::agent::GlobalProviderResolver;
205 Self::new_with_resolver(
206 provider,
207 config,
208 tools,
209 state,
210 Arc::new(GlobalProviderResolver),
211 )
212 }
213
214 pub fn with_before_tool_call(mut self, hook: BeforeToolCallHook) -> Self {
217 self.before_tool_call = Some(hook);
218 self
219 }
220
221 pub fn with_after_tool_call(mut self, hook: AfterToolCallHook) -> Self {
224 self.after_tool_call = Some(hook);
225 self
226 }
227
228 pub fn steer(&self, message: Message) {
234 if !try_push_steering(self, message) {
235 tracing::warn!("Steering message dropped — queue at capacity");
236 }
237 }
238
239 pub fn follow_up(&self, message: Message) {
245 if !try_push_follow_up(self, message) {
246 tracing::warn!("Follow-up message dropped — queue at capacity");
247 }
248 }
249
250 pub fn clear_steering_queue(&self) {
253 clear_steering_queue(self);
254 }
255
256 pub fn clear_follow_up_queue(&self) {
259 clear_follow_up_queue(self);
260 }
261
262 pub fn clear_all_queues(&self) {
264 clear_all_queues(self);
265 }
266
267 fn drain_steering_queue(&self) -> Vec<Message> {
268 drain_steering_queue(self)
269 }
270
271 #[cfg_attr(test, allow(dead_code))]
274 pub(crate) fn build_tool_context(&self) -> ToolContext {
275 let workspace = self
276 .config
277 .workspace_dir
278 .clone()
279 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
280 ToolContext {
281 workspace_dir: workspace,
282 root_dir: self.config.workspace_dir.clone(),
283 session_id: self.session_id.clone(),
284 snapshot_store: self.config.snapshot_store.clone(),
285 memory: self.config.memory.clone(),
286 url_resolver: self.config.url_resolver.clone(),
287 todo: self.config.todo.clone(),
288 agent_pool: self.config.agent_pool.clone(),
289 lsp: self.config.lsp.clone(),
290 subagent_runner: self.config.subagent_runner.clone(),
291 subagent_depth: self.config.subagent_depth,
292 intent: None,
293 }
294 }
295
296 fn maybe_truncate_tool_result(
304 &self,
305 mut result: oxicode_ai::ToolResultMessage,
306 ) -> oxicode_ai::ToolResultMessage {
307 let Some(max_bytes) = self.config.max_tool_result_bytes else {
308 return result;
309 };
310
311 for block in &mut result.content {
312 if let oxicode_ai::ContentBlock::Text(tc) = block
313 && tc.text.len() > max_bytes
314 {
315 let omitted = tc.text.len() - max_bytes;
316 tc.text.truncate(max_bytes);
317 tc.text.push_str(&format!(
318 "\n\n... [truncated: {omitted} bytes omitted, \
319 use read/grep for full content]"
320 ));
321 }
322 }
323
324 result
325 }
326
327 fn drain_follow_up_queue(&self) -> Vec<Message> {
328 drain_follow_up_queue(self)
329 }
330
331 pub fn cancel_auto_retry(&self) {
335 cancel_auto_retry(self);
336 }
337
338 pub fn auto_retry_attempt(&self) -> usize {
341 auto_retry_attempt_method(self)
342 }
343
344 pub fn state(&self) -> &SharedState {
347 &self.state
348 }
349
350 pub fn external_stop(&self) -> &Arc<AtomicBool> {
352 &self.external_stop
353 }
354
355 pub fn set_cancel_signal(&mut self, flag: Arc<AtomicBool>) {
360 self.cancel_signal = Some(flag);
361 }
362 pub fn set_auto_retry_state(
368 &mut self,
369 enabled: Arc<AtomicBool>,
370 cancel: Arc<AtomicBool>,
371 notify: Arc<tokio::sync::Notify>,
372 ) {
373 cancel.store(false, Ordering::SeqCst);
374 self.auto_retry_enabled_override = Some(enabled);
375 self.auto_retry_cancel_signal = Some(cancel);
376 self.auto_retry_notify_signal = Some(notify);
377 }
378
379 pub(crate) fn auto_retry_enabled(&self) -> bool {
382 self.auto_retry_enabled_override
383 .as_ref()
384 .map_or(self.config.auto_retry_enabled, |f| f.load(Ordering::SeqCst))
385 }
386
387 pub(crate) fn auto_retry_cancelled(&self) -> bool {
389 self.auto_retry_cancel.load(Ordering::SeqCst)
390 || self
391 .auto_retry_cancel_signal
392 .as_ref()
393 .is_some_and(|c| c.load(Ordering::SeqCst))
394 }
395
396 pub(crate) fn reset_auto_retry_cancel(&self) {
398 self.auto_retry_cancel.store(false, Ordering::SeqCst);
399 if let Some(c) = &self.auto_retry_cancel_signal {
400 c.store(false, Ordering::SeqCst);
401 }
402 }
403
404 pub(crate) fn fire_auto_retry_cancel(&self) {
407 self.auto_retry_cancel.store(true, Ordering::SeqCst);
408 if let Some(c) = &self.auto_retry_cancel_signal {
409 c.store(true, Ordering::SeqCst);
410 }
411 self.auto_retry_notify.notify_waiters();
412 if let Some(n) = &self.auto_retry_notify_signal {
413 n.notify_waiters();
414 }
415 }
416
417 pub(crate) async fn external_auto_retry_notified(&self) {
421 match &self.auto_retry_notify_signal {
422 Some(n) => n.notified().await,
423 None => std::future::pending::<()>().await,
424 }
425 }
426
427 pub fn cancel_signal(&self) -> Option<Arc<AtomicBool>> {
432 self.cancel_signal.as_ref().map(Arc::clone)
433 }
434
435 pub fn is_cancelled(&self) -> bool {
438 if self.external_stop.load(Ordering::SeqCst) {
439 return true;
440 }
441 self.cancel_signal
442 .as_ref()
443 .is_some_and(|f| f.load(Ordering::SeqCst))
444 }
445 pub fn cancel(&self) {
450 self.external_stop.store(true, Ordering::SeqCst);
451 }
452
453 pub fn set_steering_hook(&mut self, hook: Arc<dyn Fn() -> Vec<Message> + Send + Sync>) {
456 self.steering_hook = Some(hook);
457 }
458
459 pub fn set_follow_up_hook(&mut self, hook: Arc<dyn Fn() -> Vec<Message> + Send + Sync>) {
462 self.follow_up_hook = Some(hook);
463 }
464
465 fn poll_external_queues(&self) {
468 if let Some(ref hook) = self.steering_hook {
469 for msg in hook() {
470 self.steer(msg);
471 }
472 }
473 if let Some(ref hook) = self.follow_up_hook {
474 for msg in hook() {
475 self.follow_up(msg);
476 }
477 }
478 }
479
480 pub async fn run(
483 &self,
484 prompt: String,
485 emit: impl Fn(AgentEvent) + Send + Sync + 'static,
486 ) -> Result<Vec<AgentEvent>> {
487 let message = Message::User(UserMessage::new(prompt));
488 let emit = Arc::new(emit);
489 self.run_messages(vec![message], emit).await
490 }
491
492 pub async fn run_message(
496 &self,
497 message: Message,
498 emit: impl Fn(AgentEvent) + Send + Sync + 'static,
499 ) -> Result<Vec<AgentEvent>> {
500 let emit = Arc::new(emit);
501 self.run_messages(vec![message], emit).await
502 }
503
504 pub async fn run_mut<S: Send + std::fmt::Debug + 'static>(
535 &self,
536 prompt: String,
537 state: S,
538 emit: impl FnMut(AgentEvent, &mut S) + Send + 'static,
539 ) -> Result<(Vec<AgentEvent>, S)> {
540 let emit_fnmut = Arc::new(parking_lot::Mutex::new(emit));
541 let state_arc = Arc::new(parking_lot::Mutex::new(state));
542
543 let state_for_closure = Arc::clone(&state_arc);
545
546 let emit_fn: EmitFn = Arc::new(move |event: AgentEvent| {
547 let mut cb = emit_fnmut.lock();
548 let mut s = state_for_closure.lock();
549 cb(event, &mut s);
550 });
551
552 let events = self.run_inner(prompt, emit_fn).await?;
553
554 #[allow(clippy::expect_used)]
560 let mutex = Arc::try_unwrap(state_arc)
561 .expect("run_mut: state Arc still has multiple owners after run");
562 Ok((events, mutex.into_inner()))
563 }
564
565 async fn run_inner(&self, prompt: String, emit: EmitFn) -> Result<Vec<AgentEvent>> {
567 let message = Message::User(UserMessage::new(prompt));
568 self.run_messages(vec![message], emit).await
569 }
570
571 pub async fn run_messages(
574 &self,
575 prompts: Vec<Message>,
576 emit: EmitFn,
577 ) -> Result<Vec<AgentEvent>> {
578 let mut all_events = Vec::new();
579
580 let state_messages = self.state.get_state().messages.clone();
581 let mut all_messages = state_messages;
582 all_messages.extend(prompts.clone());
583
584 tracing::info!(session_id = ?self.session_id, "AgentLoop starting");
585 emit(AgentEvent::AgentStart {
586 prompts: prompts.clone(),
587 session_id: self.session_id.clone(),
588 });
589 all_events.push(AgentEvent::AgentStart {
590 prompts: prompts.clone(),
591 session_id: self.session_id.clone(),
592 });
593
594 let (result_messages, events) = self.run_loop(prompts, emit.clone()).await?;
595
596 all_events.extend(events);
597
598 let stop_reason = result_messages.last().and_then(|m| {
599 if let Message::Assistant(a) = m {
600 Some(format!("{:?}", a.stop_reason))
601 } else {
602 None
603 }
604 });
605
606 tracing::info!(session_id = ?self.session_id, "AgentLoop run_messages complete");
607
608 self.state.update(|s| {
610 s.replace_messages(result_messages.clone());
611 });
612
613 emit(AgentEvent::AgentEnd {
614 messages: result_messages.clone(),
615 stop_reason: stop_reason.clone(),
616 session_id: self.session_id.clone(),
617 });
618 all_events.push(AgentEvent::AgentEnd {
619 messages: result_messages.clone(),
620 stop_reason,
621 session_id: self.session_id.clone(),
622 });
623
624 Ok(all_events)
625 }
626
627 pub async fn continue_loop(
630 &self,
631 emit: impl Fn(AgentEvent) + Send + Sync + 'static,
632 ) -> Result<Vec<AgentEvent>> {
633 let emit = Arc::new(emit);
634 let mut all_events = Vec::new();
635
636 tracing::info!(session_id = ?self.session_id, "AgentLoop continuing");
637 emit(AgentEvent::AgentStart {
638 prompts: vec![],
639 session_id: self.session_id.clone(),
640 });
641 all_events.push(AgentEvent::AgentStart {
642 prompts: vec![],
643 session_id: self.session_id.clone(),
644 });
645
646 let (result_messages, events) = self.run_loop(vec![], emit.clone()).await?;
647
648 all_events.extend(events);
649
650 let stop_reason = result_messages.last().and_then(|m| {
651 if let Message::Assistant(a) = m {
652 Some(format!("{:?}", a.stop_reason))
653 } else {
654 None
655 }
656 });
657
658 tracing::info!(session_id = ?self.session_id, "AgentLoop continue_loop complete");
659 emit(AgentEvent::AgentEnd {
660 messages: result_messages.clone(),
661 stop_reason: stop_reason.clone(),
662 session_id: self.session_id.clone(),
663 });
664 all_events.push(AgentEvent::AgentEnd {
665 messages: result_messages.clone(),
666 stop_reason,
667 session_id: self.session_id.clone(),
668 });
669
670 Ok(all_events)
671 }
672
673 fn process_steering_messages(
675 &self,
676 pending_messages: &mut Vec<Message>,
677 messages: &mut Vec<Message>,
678 new_messages: &mut Vec<Message>,
679 events: &mut Vec<AgentEvent>,
680 emit: &EmitFn,
681 ) {
682 if pending_messages.is_empty() {
683 return;
684 }
685 for message in pending_messages.drain(..) {
686 emit(AgentEvent::SteeringMessage {
687 message: message.clone(),
688 });
689 emit(AgentEvent::MessageStart {
690 message: message.clone(),
691 });
692 emit(AgentEvent::MessageEnd {
693 message: message.clone(),
694 });
695 events.push(AgentEvent::SteeringMessage {
696 message: message.clone(),
697 });
698 events.push(AgentEvent::MessageStart {
699 message: message.clone(),
700 });
701 events.push(AgentEvent::MessageEnd {
702 message: message.clone(),
703 });
704 messages.push(message.clone());
705 new_messages.push(message);
706 }
707 }
708
709 async fn handle_streaming_error(
711 &self,
712 e: anyhow::Error,
713 messages: &mut Vec<Message>,
714 new_messages: &mut Vec<Message>,
715 events: &mut Vec<AgentEvent>,
716 emit: &EmitFn,
717 turn_number: u32,
718 ) -> (Vec<Message>, Vec<AgentEvent>) {
719 let err_msg = format!("{}", e);
720 tracing::error!(session_id = ?self.session_id, "Unexpected streaming error: {}", err_msg);
721
722 let mut error_asst = oxicode_ai::AssistantMessage::new(
723 oxicode_ai::Api::OpenAiCompletions,
724 "agent",
725 &self.config.model_id,
726 );
727 error_asst.stop_reason = StopReason::Error;
728 error_asst
729 .content
730 .push(ContentBlock::Text(TextContent::new(format!(
731 "⚠ {}",
732 err_msg
733 ))));
734
735 new_messages.push(Message::Assistant(error_asst.clone()));
736 messages.push(Message::Assistant(error_asst.clone()));
737
738 emit(AgentEvent::MessageStart {
739 message: Message::Assistant(error_asst.clone()),
740 });
741 emit(AgentEvent::MessageEnd {
742 message: Message::Assistant(error_asst.clone()),
743 });
744 emit(AgentEvent::Error {
745 message: err_msg.clone(),
746 session_id: self.session_id.clone(),
747 });
748
749 emit(AgentEvent::TurnEnd {
750 turn_number,
751 assistant_message: Message::Assistant(error_asst.clone()),
752 tool_results: vec![],
753 });
754 events.push(AgentEvent::TurnEnd {
755 turn_number,
756 assistant_message: Message::Assistant(error_asst),
757 tool_results: vec![],
758 });
759 (messages.clone(), events.clone())
761 }
762
763 async fn run_loop(
764 &self,
765 initial_prompts: Vec<Message>,
766 emit: EmitFn,
767 ) -> Result<(Vec<Message>, Vec<AgentEvent>)> {
768 tracing::info!("[AGENT-LOOP] run_loop started");
769 let mut messages = self.state.get_state().messages.clone();
770 messages.extend(initial_prompts.clone());
771
772 let mut new_messages: Vec<Message> = initial_prompts;
773 let mut events = Vec::new();
774 let mut turn_number: u32 = 0;
775 let mut first_turn = true;
776
777 let mut pending_messages: Vec<Message> = self.drain_steering_queue();
778
779 let mut todo_reminder_state = StopReminderState::default();
783 let mut mid_run_nudge_state = MidRunNudgeState::default();
786
787 let mut append_only =
789 crate::agent_loop::append_only::AppendOnlyContext::new(messages.clone());
790
791 loop {
792 tracing::info!(
793 "[AGENT-LOOP] Top of loop, has_more_tool_calls={}, pending_messages={}",
794 true,
795 pending_messages.is_empty()
796 );
797 let mut has_more_tool_calls = true;
798
799 while has_more_tool_calls || !pending_messages.is_empty() {
800 let is_first_turn = first_turn;
801 if !first_turn {
802 turn_number += 1;
803 emit(AgentEvent::TurnStart { turn_number });
804 events.push(AgentEvent::TurnStart { turn_number });
805 } else {
806 first_turn = false;
807 turn_number = 1;
808 emit(AgentEvent::TurnStart { turn_number });
809 events.push(AgentEvent::TurnStart { turn_number });
810 }
811
812 if !pending_messages.is_empty() {
813 self.process_steering_messages(
814 &mut pending_messages,
815 &mut messages,
816 &mut new_messages,
817 &mut events,
818 &emit,
819 );
820 }
821
822 self.poll_external_queues();
825
826 self.maybe_compact(&mut messages, turn_number as usize, &emit)
827 .await;
828
829 append_only.sync_from(&messages);
832
833 tracing::info!("[AGENT-LOOP] About to call stream_assistant_response");
834 let ttsr = self.ttsr_engine.as_deref();
835 let outcome =
836 stream_assistant_response(self, &mut messages, &emit, ttsr, is_first_turn)
837 .await;
838
839 let assistant_message = match outcome {
840 StreamOutcome::Complete(msg) => msg,
841 StreamOutcome::Error {
842 message: _message,
843 detail,
844 } => {
845 let is_tool_ordering_error = detail.contains("tool")
848 && (detail.contains("must be a response")
849 || detail.contains("preceding")
850 || detail.contains("tool_calls"));
851
852 if is_tool_ordering_error {
853 let removed = sanitize_orphaned_tool_results(&mut messages);
854 tracing::warn!(
855 session_id = ?self.session_id,
856 removed,
857 detail = %detail,
858 "Message-ordering error detected, removed orphaned tool results, retrying"
859 );
860 if removed > 0 {
861 emit(AgentEvent::Error {
863 message: format!(
864 "⚠ Provider rejected message order: {}. Removed {} orphaned tool results, retrying…",
865 detail, removed
866 ),
867 session_id: self.session_id.clone(),
868 });
869 continue; }
871 }
872
873 return Ok(self
875 .handle_streaming_error(
876 anyhow::anyhow!("Provider stream error: {}", detail),
877 &mut messages,
878 &mut new_messages,
879 &mut events,
880 &emit,
881 turn_number,
882 )
883 .await);
884 }
885 StreamOutcome::Cancelled(msg) => {
886 emit(AgentEvent::TurnEnd {
887 turn_number,
888 assistant_message: Message::Assistant(msg.clone()),
889 tool_results: vec![],
890 });
891 return Ok((messages, events));
892 }
893 StreamOutcome::RuleInterrupt { partial, rule } => {
894 tracing::info!("RuleInterrupt: '{}' violated, retrying", rule.name);
895 emit(AgentEvent::TtsrInterrupt {
896 rule_name: rule.name.clone(),
897 session_id: self.session_id.clone(),
898 });
899 messages.push(Message::Assistant(partial));
900 let interrupt_body = TTSR_INTERRUPT_TEMPLATE
905 .replace("{name}", &rule.name)
906 .replace("{content}", &rule.content);
907 messages.push(Message::user(interrupt_body));
908 continue;
909 }
910 };
911
912 new_messages.push(Message::Assistant(assistant_message.clone()));
913
914 if matches!(assistant_message.stop_reason, StopReason::Error) {
915 if is_retryable_error(&assistant_message) {
916 let did_retry =
917 handle_retryable_error(self, &assistant_message, &mut messages, &emit)
918 .await;
919 if did_retry {
920 emit(AgentEvent::TurnEnd {
921 turn_number,
922 assistant_message: Message::Assistant(assistant_message.clone()),
923 tool_results: vec![],
924 });
925 events.push(AgentEvent::TurnEnd {
926 turn_number,
927 assistant_message: Message::Assistant(assistant_message.clone()),
928 tool_results: vec![],
929 });
930 has_more_tool_calls = true;
931 continue;
932 }
933 }
934
935 emit(AgentEvent::TurnEnd {
936 turn_number,
937 assistant_message: Message::Assistant(assistant_message.clone()),
938 tool_results: vec![],
939 });
940 events.push(AgentEvent::TurnEnd {
941 turn_number,
942 assistant_message: Message::Assistant(assistant_message.clone()),
943 tool_results: vec![],
944 });
945 return Ok((messages, events));
946 }
947 if matches!(assistant_message.stop_reason, StopReason::Aborted) {
948 if self.auto_retry_attempt.load(Ordering::Relaxed) > 0 {
949 emit(AgentEvent::AutoRetryEnd {
950 success: true,
951 attempt: self.auto_retry_attempt.load(Ordering::Relaxed),
952 final_error: None,
953 });
954 self.auto_retry_attempt.store(0, Ordering::Relaxed);
955 }
956
957 emit(AgentEvent::TurnEnd {
958 turn_number,
959 assistant_message: Message::Assistant(assistant_message.clone()),
960 tool_results: vec![],
961 });
962 events.push(AgentEvent::TurnEnd {
963 turn_number,
964 assistant_message: Message::Assistant(assistant_message.clone()),
965 tool_results: vec![],
966 });
967 return Ok((messages, events));
968 }
969
970 if self.auto_retry_attempt.load(Ordering::Relaxed) > 0 {
971 emit(AgentEvent::AutoRetryEnd {
972 success: true,
973 attempt: self.auto_retry_attempt.load(Ordering::Relaxed),
974 final_error: None,
975 });
976 self.auto_retry_attempt.store(0, Ordering::Relaxed);
977 }
978
979 let tool_calls = helpers::extract_tool_calls(&assistant_message);
980 tracing::info!(
981 "[AGENT-LOOP] extract_tool_calls found {} calls, stop_reason={:?}",
982 tool_calls.len(),
983 assistant_message.stop_reason
984 );
985
986 let mut tool_results: Vec<oxicode_ai::ToolResultMessage> = Vec::new();
987 has_more_tool_calls = false;
988
989 if !tool_calls.is_empty() {
990 tracing::info!("[AGENT-LOOP] Executing {} tool calls", tool_calls.len());
991 let ctx = self.build_tool_context();
992 let executed_batch = match execute_tool_calls(
993 self,
994 &mut messages,
995 &assistant_message,
996 tool_calls,
997 &emit,
998 &ctx,
999 )
1000 .await
1001 {
1002 Ok(batch) => batch,
1003 Err(e) => {
1004 tracing::error!(session_id = ?self.session_id, "Tool execution error: {}", e);
1007 emit(AgentEvent::Error {
1008 message: format!("Tool execution error: {}", e),
1009 session_id: self.session_id.clone(),
1010 });
1011 emit(AgentEvent::TurnEnd {
1012 turn_number,
1013 assistant_message: Message::Assistant(assistant_message.clone()),
1014 tool_results: vec![],
1015 });
1016 events.push(AgentEvent::TurnEnd {
1017 turn_number,
1018 assistant_message: Message::Assistant(assistant_message.clone()),
1019 tool_results: vec![],
1020 });
1021 return Ok((messages, events));
1022 }
1023 };
1024
1025 tool_results = executed_batch.messages;
1026 has_more_tool_calls = !executed_batch.terminate;
1027
1028 if executed_batch.terminate {
1029 tracing::warn!(
1030 session_id = ?self.session_id,
1031 "Tool batch terminated early (terminate flag set by after_tool_call hook). \
1032 This halts the tool-calling loop. If this is unexpected, \
1033 check after_tool_call hooks for unintended terminate: true."
1034 );
1035 }
1036
1037 for result in &tool_results {
1038 mid_run_nudge_state.record_tool_result(&result.tool_name, result.is_error);
1039 let result = self.maybe_truncate_tool_result(result.clone());
1040 messages.push(Message::ToolResult(result.clone()));
1041 new_messages.push(Message::ToolResult(result));
1042 }
1043 if has_more_tool_calls {
1045 use oxicode_ai::utils::tool_call_loop::{
1046 ToolCallLoopTurn, ToolCallRef, ToolResultRef,
1047 };
1048 let call_refs: Vec<ToolCallRef> = assistant_message
1049 .content
1050 .iter()
1051 .filter_map(|block| match block {
1052 oxicode_ai::ContentBlock::ToolCall(tc) => Some(ToolCallRef {
1053 id: tc.id.clone(),
1054 name: tc.name.clone(),
1055 arguments: tc.arguments.clone(),
1056 }),
1057 _ => None,
1058 })
1059 .collect();
1060 let result_refs: Vec<ToolResultRef> = tool_results
1061 .iter()
1062 .map(|tr| {
1063 let text: String = tr
1064 .content
1065 .iter()
1066 .filter_map(|b| match b {
1067 oxicode_ai::ContentBlock::Text(t) => Some(t.text.as_str()),
1068 _ => None,
1069 })
1070 .collect::<Vec<_>>()
1071 .join("\n");
1072 ToolResultRef {
1073 tool_call_id: tr.tool_call_id.clone(),
1074 content: text,
1075 }
1076 })
1077 .collect();
1078 let turn = ToolCallLoopTurn {
1079 tool_calls: &call_refs,
1080 tool_results: &result_refs,
1081 };
1082 let detection = self.tool_call_loop_guard.lock().record_turn(turn);
1088 if let Some(detection) = detection {
1089 let steering = format!(
1090 "Tool-call loop detected: '{}' called {} consecutive \
1091 times with identical arguments. Result: '{}'. \
1092 Try a different approach.",
1093 detection.tool_name, detection.count, detection.result_summary,
1094 );
1095 let msg = Message::User(oxicode_ai::UserMessage::new(steering));
1096 messages.push(msg.clone());
1097 new_messages.push(msg);
1098 tracing::warn!(
1099 session_id = ?self.session_id,
1100 tool = %detection.tool_name,
1101 count = detection.count,
1102 "tool-call loop detected; injecting steering message"
1103 );
1104 self.tool_call_loop_guard.lock().reset();
1105 }
1106 }
1107 }
1108
1109 if let Some(text) = mid_run_nudge_state.take_nudge() {
1113 tracing::info!(
1114 session_id = ?self.session_id,
1115 "[AGENT-LOOP] injecting mid-run todo reconciliation nudge"
1116 );
1117 pending_messages.push(Message::User(UserMessage::hidden(text)));
1118 }
1119
1120 let assistant_has_tool_calls = assistant_message
1124 .content
1125 .iter()
1126 .any(|b| matches!(b, oxicode_ai::ContentBlock::ToolCall(_)));
1127 if !self.config.soft_requirements.is_empty() && assistant_has_tool_calls {
1128 let called_tools: std::collections::HashSet<String> = assistant_message
1129 .content
1130 .iter()
1131 .filter_map(|block| match block {
1132 oxicode_ai::ContentBlock::ToolCall(tc) => Some(tc.name.clone()),
1133 _ => None,
1134 })
1135 .collect();
1136
1137 for req in &self.config.soft_requirements {
1138 if called_tools.contains(&req.tool_name) {
1139 self.soft_requirement_state
1140 .lock()
1141 .reminded
1142 .remove(&req.tool_name);
1143 continue;
1144 }
1145
1146 if self
1147 .soft_requirement_state
1148 .lock()
1149 .reminded
1150 .contains(&req.tool_name)
1151 {
1152 tracing::warn!(
1153 session_id = ?self.session_id,
1154 tool = %req.tool_name,
1155 "Soft requirement escalation"
1156 );
1157 emit(AgentEvent::SoftRequirementEscalation {
1158 tool_name: req.tool_name.clone(),
1159 reason: req.reason.clone(),
1160 session_id: self.session_id.clone(),
1161 });
1162 let escalate_msg = Message::User(oxicode_ai::UserMessage::new(
1163 format!(
1164 "[IMPORTANT] You still have not used the `{}` tool, which is required. {}",
1165 req.tool_name, req.reason,
1166 ),
1167 ));
1168 messages.push(escalate_msg.clone());
1169 new_messages.push(escalate_msg);
1170 } else {
1171 tracing::info!(
1172 session_id = ?self.session_id,
1173 tool = %req.tool_name,
1174 "Soft requirement reminder"
1175 );
1176 self.soft_requirement_state
1177 .lock()
1178 .reminded
1179 .insert(req.tool_name.clone());
1180 emit(AgentEvent::SoftRequirementReminder {
1181 tool_name: req.tool_name.clone(),
1182 reason: req.reason.clone(),
1183 session_id: self.session_id.clone(),
1184 });
1185 let reminder_msg =
1186 Message::User(oxicode_ai::UserMessage::new(format!(
1187 "Reminder: please use the `{}` tool. {}",
1188 req.tool_name, req.reason,
1189 )));
1190 messages.push(reminder_msg.clone());
1191 new_messages.push(reminder_msg);
1192 }
1193 }
1194 }
1195
1196 emit(AgentEvent::TurnEnd {
1197 turn_number,
1198 assistant_message: Message::Assistant(assistant_message.clone()),
1199 tool_results: tool_results.clone(),
1200 });
1201 events.push(AgentEvent::TurnEnd {
1202 turn_number,
1203 assistant_message: Message::Assistant(assistant_message.clone()),
1204 tool_results: tool_results.clone(),
1205 });
1206
1207 if should_stop_after_turn(&self.external_stop) {
1208 tracing::info!("[AGENT-LOOP] external_stop, ending loop");
1209 return Ok((messages, events));
1210 }
1211
1212 pending_messages = self.drain_steering_queue();
1213 tracing::info!(
1214 "[AGENT-LOOP] TurnEnd complete, pending_messages={}, has_more_tool_calls={}",
1215 !pending_messages.is_empty(),
1216 has_more_tool_calls
1217 );
1218
1219 if self.external_stop.load(Ordering::SeqCst) {
1222 tracing::info!(
1223 "[AGENT-LOOP] external_stop set after steering drain, ending loop"
1224 );
1225 return Ok((messages, events));
1226 }
1227 }
1228
1229 let late_steering = self.drain_steering_queue();
1233 if !late_steering.is_empty() {
1234 tracing::info!(
1235 count = late_steering.len(),
1236 "[AGENT-LOOP] Caught late steering messages after inner loop exit"
1237 );
1238 pending_messages = late_steering;
1239 continue;
1240 }
1241
1242 let follow_up_messages = self.drain_follow_up_queue();
1243 if !follow_up_messages.is_empty() {
1244 pending_messages = follow_up_messages;
1245 continue;
1246 }
1247
1248 let final_steering = self.drain_steering_queue();
1251 if !final_steering.is_empty() {
1252 pending_messages = final_steering;
1253 continue;
1254 }
1255
1256 if self.config.todo_reminders_enabled
1261 && let Some(provider) = self.config.todo.as_ref()
1262 && let phases = provider.get_phases()
1263 && let Some(text) = build_stop_reminder(
1264 &phases,
1265 &mut todo_reminder_state,
1266 self.config.todo_reminders_max,
1267 )
1268 {
1269 tracing::info!(
1270 count = todo_reminder_state.count(),
1271 "[AGENT-LOOP] injecting incomplete-todo stop reminder"
1272 );
1273 let open: Vec<crate::tools::todo::TodoItem> = phases
1274 .iter()
1275 .flat_map(|p| p.tasks.iter())
1276 .filter(|t| {
1277 matches!(
1278 t.status,
1279 crate::tools::todo::TodoStatus::Pending
1280 | crate::tools::todo::TodoStatus::InProgress
1281 )
1282 })
1283 .cloned()
1284 .collect();
1285 let event = AgentEvent::TodoReminder {
1286 attempt: todo_reminder_state.count(),
1287 max: self.config.todo_reminders_max,
1288 open: open.clone(),
1289 };
1290 emit(event.clone());
1291 events.push(event);
1292 pending_messages = vec![Message::User(UserMessage::hidden(text))];
1295 continue;
1296 }
1297 break;
1298 }
1299
1300 append_only.sync_from(&messages);
1302
1303 Ok((messages, events))
1304 }
1305
1306 fn build_compaction_instruction(&self) -> Option<String> {
1309 let base = self.config.compaction_instruction.as_deref();
1310 let injected = self
1311 .ttsr_engine
1312 .as_ref()
1313 .map(|e| e.injected_records())
1314 .unwrap_or_default();
1315 if injected.is_empty() {
1316 return base.map(|s| s.to_string());
1317 }
1318 let mut instr = base.map(|s| s.to_string()).unwrap_or_default();
1319 instr.push_str("\n\nThe following rules have already been enforced in this session and corrections applied. Do NOT violate them again:");
1320 for (name, _turn) in &injected {
1321 instr.push_str(&format!("\n- {name}"));
1322 }
1323 Some(instr)
1324 }
1325
1326 async fn maybe_compact(&self, messages: &mut Vec<Message>, iteration: usize, emit: &EmitFn) {
1327 let snapshot = self.state.get_state();
1343 let (context_tokens, source_label) = match snapshot.current_token_source() {
1344 TokenSource::Real(n) => (n, "provider-reported"),
1345 TokenSource::Heuristic(n) => (n, "bytes/4 heuristic (cold start)"),
1346 TokenSource::None => (0, "empty"),
1347 };
1348 if let Some(div) = snapshot.last_estimate_divergence
1353 && div > 2.0
1354 {
1355 tracing::warn!(
1356 session_id = ?self.session_id,
1357 divergence = div,
1358 reported = snapshot.last_input_tokens.unwrap_or(0),
1359 estimate = snapshot.last_estimate_at_report.unwrap_or(0),
1360 "Token-count heuristic (bytes/4) diverges from provider-reported usage \
1361 by >2x; CompactionStrategy::Threshold decisions are using the \
1362 provider-reported count (issue #28 gap 2)."
1363 );
1364 }
1365 drop(snapshot);
1366
1367 if !self
1368 .compaction_manager
1369 .should_compact(context_tokens, iteration)
1370 {
1371 return;
1372 }
1373
1374 let shake_config = compaction::shake::ShakeConfig::default();
1379 match compaction::shake::shake(messages, &shake_config) {
1380 compaction::shake::ShakeOutcome::Shaken {
1381 regions_elided,
1382 tokens_saved,
1383 } => {
1384 tracing::info!(
1385 session_id = ?self.session_id,
1386 regions_elided,
1387 tokens_saved,
1388 "Shake compaction recovered {} tokens ({} regions), skipping LLM compaction",
1389 tokens_saved,
1390 regions_elided
1391 );
1392 emit(AgentEvent::Compaction {
1393 event: CompactionEvent::Triggered {
1394 context_tokens,
1395 iteration,
1396 source: format!(
1397 "shake ({} tokens, {} regions)",
1398 tokens_saved, regions_elided
1399 ),
1400 },
1401 });
1402 return; }
1404 compaction::shake::ShakeOutcome::NoChange => {
1405 }
1407 }
1408
1409 emit(AgentEvent::Compaction {
1410 event: CompactionEvent::Triggered {
1411 context_tokens,
1412 iteration,
1413 source: source_label.to_string(),
1414 },
1415 });
1416
1417 let messages_to_compact: Vec<Message> = messages.to_vec();
1418 let instruction = self.build_compaction_instruction();
1419
1420 match self
1421 .compaction_manager
1422 .compact_if_needed(
1423 &messages_to_compact,
1424 instruction.as_deref(),
1425 context_tokens,
1426 iteration,
1427 )
1428 .await
1429 {
1430 Ok(Some(compacted)) => {
1431 let start = Instant::now();
1432 let message_count = compacted.compacted_count;
1433
1434 emit(AgentEvent::Compaction {
1435 event: CompactionEvent::Started { message_count },
1436 });
1437
1438 let kept_messages = compacted.kept_messages;
1439 let summary = compacted.summary;
1440 let compacted_count = compacted.compacted_count;
1441
1442 *messages = kept_messages;
1443
1444 let state_msgs = messages.clone();
1445 self.state.update(|s| {
1446 s.replace_messages(state_msgs);
1447 });
1448
1449 let compacted_ctx = CompactedContext {
1450 summary,
1451 kept_messages: Vec::new(),
1452 compacted_count,
1453 };
1454 emit(AgentEvent::Compaction {
1455 event: CompactionEvent::Completed {
1456 result: compacted_ctx.clone(),
1457 duration_ms: start.elapsed().as_millis() as u64,
1458 },
1459 });
1460
1461 if let Some(ref hook) = self.config.on_compaction {
1463 match hook(compacted_ctx).await {
1464 Ok(()) => {
1465 tracing::debug!("Compaction hook completed successfully");
1466 }
1467 Err(e) => {
1468 tracing::warn!(error = %e, "Compaction hook failed");
1469 }
1470 }
1471 }
1472 }
1473 Ok(None) => {}
1474 Err(e) => {
1475 emit(AgentEvent::Compaction {
1476 event: CompactionEvent::Failed {
1477 error: e.to_string(),
1478 },
1479 });
1480 }
1481 }
1482 }
1483
1484 fn resolve_model(&self) -> Result<oxicode_ai::Model> {
1485 self.resolver
1486 .resolve_model(&self.config.model_id)
1487 .ok_or_else(|| Error::msg(format!("Model not found: {}", self.config.model_id)))
1488 }
1489}
1490
1491#[cfg(test)]
1492mod session_id_wiring_tests {
1493 use super::*;
1498 use crate::ProviderResolver;
1499 use crate::agent_loop::config::AgentLoopConfig;
1500 use crate::config::ToolExecutionMode;
1501 use crate::state::SharedState;
1502 use crate::tools::ToolRegistry;
1503 use oxicode_ai::{
1504 CompactionStrategy, Context, Model, Provider, ProviderError, StreamOptions, StreamResult,
1505 };
1506 use std::future::Future;
1507 use std::pin::Pin;
1508
1509 struct NopProvider;
1510 impl Provider for NopProvider {
1511 fn stream<'a>(
1512 &'a self,
1513 _model: &'a Model,
1514 _context: &'a Context,
1515 _options: Option<StreamOptions>,
1516 ) -> Pin<Box<dyn Future<Output = StreamResult> + Send + 'a>> {
1517 Box::pin(async {
1518 Err(ProviderError::NotImplemented(
1519 "session-id wiring tests never stream".to_string(),
1520 ))
1521 })
1522 }
1523 }
1524
1525 struct NullResolver;
1526 impl ProviderResolver for NullResolver {
1527 fn resolve_provider(&self, _name: &str) -> Option<Arc<dyn Provider>> {
1528 None
1529 }
1530 fn resolve_model(&self, _model_id: &str) -> Option<Model> {
1531 None
1532 }
1533 }
1534
1535 fn loop_with(session_id: Option<String>) -> AgentLoop {
1536 let config = AgentLoopConfig {
1537 model_id: "test/model".to_string(),
1538 system_prompt: None,
1539 temperature: 1.0,
1540 max_tokens: 4096,
1541 tool_execution: ToolExecutionMode::Sequential,
1542 compaction_strategy: CompactionStrategy::Disabled,
1543 compaction_instruction: None,
1544 context_window: 128_000,
1545 session_id,
1546 transport: None,
1547 compact_on_start: false,
1548 max_retry_delay_ms: None,
1549 auto_retry_enabled: true,
1550 auto_retry_max_attempts: 3,
1551 auto_retry_base_delay_ms: 1000,
1552 workspace_dir: None,
1553 provider_options: None,
1554 on_compaction: None,
1555 snapshot_store: None,
1556 memory: None,
1557 url_resolver: None,
1558 todo: None,
1559 agent_pool: None,
1560 lsp: None,
1561 ttsr_engine: None,
1562 subagent_runner: None,
1563 subagent_depth: 0,
1564 max_tool_result_bytes: None,
1565 thinking_loop_detection: false, ..Default::default()
1567 };
1568 AgentLoop::new_with_resolver(
1569 Arc::new(NopProvider),
1570 config,
1571 Arc::new(ToolRegistry::new()),
1572 SharedState::new(),
1573 Arc::new(NullResolver),
1574 )
1575 }
1576
1577 #[test]
1583 fn tool_context_inherits_session_id_when_set() {
1584 let loop_ = loop_with(Some("proc-test-session-id".to_string()));
1585 let ctx = loop_.build_tool_context();
1586 assert_eq!(
1587 ctx.session_id.as_deref(),
1588 Some("proc-test-session-id"),
1589 "ToolContext.session_id must inherit AgentConfig.session_id"
1590 );
1591 }
1592
1593 #[test]
1594 fn tool_context_session_id_defaults_to_none() {
1595 let loop_ = loop_with(None);
1596 let ctx = loop_.build_tool_context();
1597 assert!(
1598 ctx.session_id.is_none(),
1599 "default ToolContext.session_id should be None"
1600 );
1601 }
1602}
1603
1604#[cfg(test)]
1607mod truncation_tests {
1608 use super::*;
1609 use crate::agent::ProviderResolver;
1610 use oxicode_ai::{
1611 ContentBlock, Context, Model, Provider, ProviderError, StreamOptions, StreamResult,
1612 TextContent, ToolResultMessage,
1613 };
1614 use std::future::Future;
1615 use std::pin::Pin;
1616
1617 struct NopProvider;
1618 impl Provider for NopProvider {
1619 fn stream<'a>(
1620 &'a self,
1621 _model: &'a Model,
1622 _context: &'a Context,
1623 _options: Option<StreamOptions>,
1624 ) -> Pin<Box<dyn Future<Output = StreamResult> + Send + 'a>> {
1625 Box::pin(async {
1626 Err(ProviderError::NotImplemented(
1627 "truncation tests never stream".to_string(),
1628 ))
1629 })
1630 }
1631 }
1632
1633 struct NullResolver;
1634 impl ProviderResolver for NullResolver {
1635 fn resolve_provider(&self, _name: &str) -> Option<Arc<dyn Provider>> {
1636 None
1637 }
1638 fn resolve_model(&self, _model_id: &str) -> Option<Model> {
1639 None
1640 }
1641 }
1642
1643 fn make_result(text: &str) -> ToolResultMessage {
1644 ToolResultMessage::new(
1645 "tc_test".to_string(),
1646 "test_tool",
1647 vec![ContentBlock::Text(TextContent::new(text.to_string()))],
1648 )
1649 }
1650
1651 fn loop_with_limit(limit: Option<usize>) -> AgentLoop {
1652 let config = AgentLoopConfig {
1653 model_id: "test/model".to_string(),
1654 max_tool_result_bytes: limit,
1655 ..Default::default()
1656 };
1657 AgentLoop::new_with_resolver(
1658 Arc::new(NopProvider),
1659 config,
1660 Arc::new(ToolRegistry::new()),
1661 SharedState::new(),
1662 Arc::new(NullResolver),
1663 )
1664 }
1665
1666 #[test]
1667 fn truncate_passthrough_when_none() {
1668 let loop_ = loop_with_limit(None);
1669 let result = make_result(&"x".repeat(10_000));
1670 let truncated = loop_.maybe_truncate_tool_result(result);
1671 if let ContentBlock::Text(tc) = &truncated.content[0] {
1672 assert_eq!(tc.text.len(), 10_000);
1673 assert!(!tc.text.contains("truncated"));
1674 }
1675 }
1676
1677 #[test]
1678 fn truncate_passthrough_when_under_limit() {
1679 let loop_ = loop_with_limit(Some(1000));
1680 let result = make_result(&"x".repeat(500));
1681 let truncated = loop_.maybe_truncate_tool_result(result);
1682 if let ContentBlock::Text(tc) = &truncated.content[0] {
1683 assert_eq!(tc.text.len(), 500);
1684 assert!(!tc.text.contains("truncated"));
1685 }
1686 }
1687
1688 #[test]
1689 fn truncate_applies_when_over_limit() {
1690 let loop_ = loop_with_limit(Some(100));
1691 let result = make_result(&"x".repeat(500));
1692 let truncated = loop_.maybe_truncate_tool_result(result);
1693 if let ContentBlock::Text(tc) = &truncated.content[0] {
1694 assert!(
1695 tc.text.len() < 500,
1696 "text not truncated: {} bytes",
1697 tc.text.len()
1698 );
1699 assert!(tc.text.contains("truncated"), "missing truncation marker");
1700 assert!(tc.text.contains("400 bytes omitted"));
1701 }
1702 }
1703}