1use futures::stream::StreamExt;
25use std::time::{SystemTime, UNIX_EPOCH};
26use tokio_util::sync::CancellationToken;
27
28use crate::config::LoopConfig;
29use crate::error::{LoopError, StreamError};
30use crate::event::AgentEvent;
31use crate::exec::{execute_tool_batch, ExecutedBatch};
32use crate::plugin::TransformContext;
33use crate::stream::{ReasoningEffort, StreamErrorKind, StreamEvent, StreamRequest, ToolSchema};
34use crate::types::{
35 AgentContext, AgentMessage, AssistantContent, StopReason, ToolResultContent, Usage,
36};
37
38const EMPTY_STREAM_RETRY_INITIAL_DELAY: std::time::Duration = std::time::Duration::from_millis(250);
39const ZERO_OUTPUT_TRANSPORT_RETRY_INITIAL_DELAY: std::time::Duration =
40 std::time::Duration::from_millis(500);
41const PROVIDER_RETRY_MAX_DELAY: std::time::Duration = std::time::Duration::from_secs(30);
42const ZERO_OUTPUT_TRANSPORT_RECOVERY_CONTEXT: &str = "\
43[runtime context — transport recovery, not user instruction]\n\
44The previous provider attempt produced no actionable output: no visible assistant text and no usable tool call reached the runtime. \
45It may have produced private-only reasoning or an unusable burst of partial tool calls. \
46Do not continue with private reasoning only. Re-read the latest observation and immediately choose exactly one next structured tool call; \
47if the answer is ready, use the final response tool.";
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum LoopOutcome {
55 Done,
58}
59
60impl LoopOutcome {
61 pub fn is_complete(self) -> bool {
63 true
64 }
65
66 pub fn label(self) -> &'static str {
68 "done"
69 }
70}
71
72#[derive(Debug, Clone)]
78pub struct RunResult {
79 pub messages: Vec<AgentMessage>,
80 pub outcome: LoopOutcome,
81}
82
83pub async fn run(
92 prompts: Vec<AgentMessage>,
93 context: AgentContext,
94 config: &LoopConfig,
95 signal: CancellationToken,
96) -> Result<RunResult, LoopError> {
97 let mut current = context;
98 let mut new_messages = prompts.clone();
99
100 current.messages.extend(prompts.iter().cloned());
101
102 emit(config, AgentEvent::AgentStart).await;
103 if let Some(identity) = current.identity.clone() {
104 emit(config, AgentEvent::RunIdentified { identity }).await;
105 }
106 emit(config, AgentEvent::TurnStart).await;
107 for prompt in &prompts {
108 emit(
109 config,
110 AgentEvent::MessageStart {
111 message: prompt.clone(),
112 },
113 )
114 .await;
115 emit(
116 config,
117 AgentEvent::MessageEnd {
118 message: prompt.clone(),
119 },
120 )
121 .await;
122 }
123
124 let outcome = inner_run(&mut current, &mut new_messages, config, &signal).await?;
125
126 Ok(RunResult {
127 messages: new_messages,
128 outcome,
129 })
130}
131
132pub async fn run_continue(
139 context: AgentContext,
140 config: &LoopConfig,
141 signal: CancellationToken,
142) -> Result<RunResult, LoopError> {
143 let last = context
144 .messages
145 .last()
146 .ok_or_else(|| LoopError::InvalidContinuation("no messages in context".into()))?;
147 if matches!(last, AgentMessage::Assistant { .. }) {
148 return Err(LoopError::InvalidContinuation(
149 "trailing message is assistant".into(),
150 ));
151 }
152
153 let mut current = context;
154 let mut new_messages = Vec::new();
155
156 emit(config, AgentEvent::AgentStart).await;
157 if let Some(identity) = current.identity.clone() {
158 emit(config, AgentEvent::RunIdentified { identity }).await;
159 }
160 emit(config, AgentEvent::TurnStart).await;
161
162 let outcome = inner_run(&mut current, &mut new_messages, config, &signal).await?;
163
164 Ok(RunResult {
165 messages: new_messages,
166 outcome,
167 })
168}
169
170async fn emit(config: &LoopConfig, event: AgentEvent) {
173 config.event_sink.emit(event.clone()).await;
174 for observer in &config.plugins.event_observer {
175 observer.on_event(&event).await;
176 }
177}
178
179async fn inner_run(
180 current: &mut AgentContext,
181 new_messages: &mut Vec<AgentMessage>,
182 config: &LoopConfig,
183 signal: &CancellationToken,
184) -> Result<LoopOutcome, LoopError> {
185 let mut first_turn = true;
186 let mut iterations: usize = 0;
187
188 let mut pending = collect_steering(config).await;
191
192 'outer: loop {
193 let mut has_more_tool_calls = true;
194 let mut last_batch_terminated = false;
210
211 while has_more_tool_calls || !pending.is_empty() {
212 if signal.is_cancelled() {
213 return Err(LoopError::Aborted);
214 }
215 iterations += 1;
216
217 if !first_turn {
218 emit(config, AgentEvent::TurnStart).await;
219 } else {
220 first_turn = false;
221 }
222
223 if !pending.is_empty() {
225 for msg in pending.drain(..) {
226 emit(
227 config,
228 AgentEvent::MessageStart {
229 message: msg.clone(),
230 },
231 )
232 .await;
233 emit(
234 config,
235 AgentEvent::MessageEnd {
236 message: msg.clone(),
237 },
238 )
239 .await;
240 current.messages.push(msg.clone());
241 new_messages.push(msg);
242 }
243 }
244
245 let (assistant, turn_allowlist) =
253 stream_with_overflow_recovery(current, config, signal, iterations - 1).await?;
254 current.messages.push(assistant.clone());
259 new_messages.push(assistant.clone());
260
261 let tool_calls: Vec<_> = match &assistant {
263 AgentMessage::Assistant { content, .. } => {
264 content.tool_calls().into_iter().cloned().collect()
265 }
266 _ => Vec::new(),
267 };
268
269 let mut tool_result_messages = Vec::new();
270 has_more_tool_calls = false;
271
272 if tool_calls.is_empty() {
273 if let Some(tool_name) = config.plain_text_terminal_fallback_tool.as_deref() {
274 let eager = config.plain_text_terminal_fallback_eager;
275 let terminal_tool_names = config.protocol.terminal_tool_names();
276 let narrowed_to_terminators = is_terminal_only_allowlist(
277 turn_allowlist.as_ref(),
278 tool_name,
279 &terminal_tool_names,
280 );
281 let nudge_mode = config.plain_text_terminal_fallback_eager_nudge
282 && eager
283 && !narrowed_to_terminators;
284 if nudge_mode {
285 let available_tool_names: Vec<&str> =
301 config.tools.iter().map(|t| t.name()).collect();
302 let nudge_text = config
303 .protocol
304 .plain_text_recovery_prompt(crate::protocol::PlainTextRecoveryContext {
305 messages: ¤t.messages,
306 iteration: iterations - 1,
307 available_tool_names: &available_tool_names,
308 terminal_fallback_tool: Some(tool_name),
309 })
310 .unwrap_or_else(|| {
311 crate::protocol::DEFAULT_PLAIN_TEXT_RECOVERY_PROMPT.to_string()
312 });
313 let nudge = AgentMessage::System {
314 content: nudge_text,
315 timestamp: Some(now_ms()),
316 };
317 current.messages.push(nudge.clone());
318 new_messages.push(nudge);
319 has_more_tool_calls = true;
320 } else if let Some(result_msg) = synthesize_plain_text_terminal_result(
321 &assistant,
322 turn_allowlist.as_ref(),
323 tool_name,
324 eager,
325 &terminal_tool_names,
326 ) {
327 last_batch_terminated = true;
328 current.messages.push(result_msg.clone());
329 new_messages.push(result_msg.clone());
330 tool_result_messages.push(result_msg);
331 }
332 }
333 } else {
334 let ExecutedBatch {
335 messages,
336 terminate,
337 } = execute_tool_batch(
338 &assistant,
339 tool_calls,
340 current,
341 config,
342 signal,
343 turn_allowlist.as_ref(),
344 )
345 .await?;
346
347 tool_result_messages = messages;
348 has_more_tool_calls = !terminate;
349 last_batch_terminated = terminate;
350
351 for result_msg in &tool_result_messages {
352 current.messages.push(result_msg.clone());
353 new_messages.push(result_msg.clone());
354 }
355 }
356
357 emit(
358 config,
359 AgentEvent::TurnEnd {
360 message: assistant,
361 tool_results: tool_result_messages,
362 },
363 )
364 .await;
365
366 pending = if last_batch_terminated {
372 Vec::new()
373 } else {
374 collect_steering(config).await
375 };
376 }
377
378 let follow_up = if last_batch_terminated {
386 Vec::new()
387 } else {
388 collect_follow_up(config).await
389 };
390 if !follow_up.is_empty() {
391 pending = follow_up;
392 continue 'outer;
393 }
394
395 break;
396 }
397
398 emit(
399 config,
400 AgentEvent::AgentEnd {
401 messages: new_messages.clone(),
402 },
403 )
404 .await;
405
406 Ok(LoopOutcome::Done)
407}
408
409async fn collect_steering(config: &LoopConfig) -> Vec<AgentMessage> {
410 let mut out = Vec::new();
411 for source in &config.plugins.steering {
412 out.extend(source.next_steering_messages().await);
413 }
414 out
415}
416
417async fn collect_follow_up(config: &LoopConfig) -> Vec<AgentMessage> {
418 let mut out = Vec::new();
419 for source in &config.plugins.follow_up {
420 out.extend(source.next_follow_up_messages().await);
421 }
422 out
423}
424
425fn synthesize_plain_text_terminal_result(
426 assistant: &AgentMessage,
427 turn_allowlist: Option<&std::collections::HashSet<String>>,
428 tool_name: &str,
429 eager: bool,
430 terminal_tool_names: &std::collections::HashSet<String>,
431) -> Option<AgentMessage> {
432 if !eager && !is_terminal_only_allowlist(turn_allowlist, tool_name, terminal_tool_names) {
439 return None;
440 }
441 let text = plain_assistant_text(assistant)?;
442 Some(AgentMessage::ToolResult {
443 tool_call_id: format!("plain_text_terminal_fallback_{}", now_ms()),
444 tool_name: tool_name.to_string(),
445 content: ToolResultContent::text(text),
446 is_error: false,
447 narration: Some(
448 "Converted plain assistant text into terminal delivery for an auto-tool-choice provider."
449 .to_string(),
450 ),
451 details: None,
452 timestamp: Some(now_ms()),
453 })
454}
455
456fn plain_assistant_text(assistant: &AgentMessage) -> Option<String> {
457 let AgentMessage::Assistant { content, .. } = assistant else {
458 return None;
459 };
460 let text = crate::strip_thinking_tags(&content.plain_text())
461 .trim()
462 .to_string();
463 (!text.is_empty()).then_some(text)
464}
465
466fn is_terminal_only_allowlist(
475 turn_allowlist: Option<&std::collections::HashSet<String>>,
476 terminal_tool: &str,
477 terminal_tool_names: &std::collections::HashSet<String>,
478) -> bool {
479 let Some(allowlist) = turn_allowlist else {
480 return false;
481 };
482 !allowlist.is_empty()
483 && allowlist.contains(terminal_tool)
484 && allowlist
485 .iter()
486 .all(|tool| tool == terminal_tool || terminal_tool_names.contains(tool))
487}
488
489async fn stream_with_overflow_recovery(
512 current: &mut AgentContext,
513 config: &LoopConfig,
514 signal: &CancellationToken,
515 iteration: usize,
516) -> Result<(AgentMessage, Option<std::collections::HashSet<String>>), LoopError> {
517 loop {
518 match stream_with_max_tokens_recovery(current, config, signal, iteration).await {
519 Err(LoopError::Stream(StreamError::ContextOverflow(message))) => {
520 let Some(recovery) = config.overflow_recovery.clone() else {
521 return Err(LoopError::Stream(StreamError::ContextOverflow(message)));
522 };
523 if signal.is_cancelled() {
524 return Err(LoopError::Stream(StreamError::ContextOverflow(message)));
525 }
526
527 let usage = last_provider_usage(¤t.messages);
530 let cx = TransformContext {
531 signal,
532 model_id: config.model_id.as_deref().unwrap_or(""),
533 iteration,
534 last_provider_usage: usage.as_ref(),
535 estimator: &*config.token_estimator,
536 };
537 let before = std::mem::take(&mut current.messages);
538 let before_size = cx.estimator.estimate_messages(&before);
539 let after = recovery.recover(before.clone(), &cx).await;
540
541 if cx.estimator.estimate_messages(&after) >= before_size {
547 current.messages = before;
548 return Err(LoopError::Stream(StreamError::ContextOverflow(message)));
549 }
550 emit(
551 config,
552 AgentEvent::ContextTransformApplied {
553 iteration,
554 plugin: recovery.name(),
555 before,
556 after: after.clone(),
557 },
558 )
559 .await;
560 current.messages = after;
561 }
563 other => return other,
564 }
565 }
566}
567
568async fn stream_with_max_tokens_recovery(
569 context: &AgentContext,
570 config: &LoopConfig,
571 signal: &CancellationToken,
572 iteration: usize,
573) -> Result<(AgentMessage, Option<std::collections::HashSet<String>>), LoopError> {
574 let mut current_cap = config.max_output_tokens;
575 let mut max_tokens_attempt: u32 = 0;
576 let mut empty_stream_attempts: u32 = 0;
577 let mut zero_output_transport_attempts: u32 = 0;
578 let mut transient_stream_attempts: u32 = 0;
579 let mut zero_output_recovery_context: Option<AgentContext> = None;
580 let mut reasoning = config.reasoning;
581
582 loop {
583 let attempt_context = zero_output_recovery_context.as_ref().unwrap_or(context);
584 let (assistant, allowlist) = match stream_assistant_response(
585 attempt_context,
586 config,
587 signal,
588 iteration,
589 current_cap,
590 reasoning,
591 )
592 .await
593 {
594 Ok(pair) => pair,
595 Err(LoopError::Stream(StreamError::Empty)) => {
596 empty_stream_attempts = empty_stream_attempts.saturating_add(1);
597 let delay =
598 provider_retry_delay(EMPTY_STREAM_RETRY_INITIAL_DELAY, empty_stream_attempts);
599 tokio::select! {
600 _ = signal.cancelled() => return Err(LoopError::Aborted),
601 _ = tokio::time::sleep(delay) => {}
602 }
603 continue;
604 }
605 Err(LoopError::Stream(StreamError::ZeroOutputTransport(_))) => {
606 zero_output_transport_attempts = zero_output_transport_attempts.saturating_add(1);
607 zero_output_recovery_context =
608 Some(context_with_zero_output_transport_recovery(context));
609 reasoning = zero_output_transport_retry_reasoning(config.reasoning);
610 let delay = provider_retry_delay(
611 ZERO_OUTPUT_TRANSPORT_RETRY_INITIAL_DELAY,
612 zero_output_transport_attempts,
613 );
614 tokio::select! {
615 _ = signal.cancelled() => return Err(LoopError::Aborted),
616 _ = tokio::time::sleep(delay) => {}
617 }
618 continue;
619 }
620 Err(LoopError::Stream(
621 StreamError::Transient(_) | StreamError::ProviderRateLimited(_),
622 )) => {
623 transient_stream_attempts = transient_stream_attempts.saturating_add(1);
624 let delay = provider_retry_delay(
625 ZERO_OUTPUT_TRANSPORT_RETRY_INITIAL_DELAY,
626 transient_stream_attempts,
627 );
628 tokio::select! {
629 _ = signal.cancelled() => return Err(LoopError::Aborted),
630 _ = tokio::time::sleep(delay) => {}
631 }
632 continue;
633 }
634 Err(err) => return Err(err),
635 };
636
637 let stop_reason = match &assistant {
638 AgentMessage::Assistant { stop_reason, .. } => *stop_reason,
639 _ => StopReason::Other,
640 };
641 if stop_reason == StopReason::Aborted {
642 return Err(LoopError::Aborted);
643 }
644 if stop_reason == StopReason::Error {
645 transient_stream_attempts = transient_stream_attempts.saturating_add(1);
646 let delay = provider_retry_delay(
647 ZERO_OUTPUT_TRANSPORT_RETRY_INITIAL_DELAY,
648 transient_stream_attempts,
649 );
650 tokio::select! {
651 _ = signal.cancelled() => return Err(LoopError::Aborted),
652 _ = tokio::time::sleep(delay) => {}
653 }
654 continue;
655 }
656 if stop_reason != StopReason::MaxTokens {
657 return Ok((assistant, allowlist));
658 }
659 let Some(prev_cap) = current_cap else {
664 return Ok((assistant, allowlist));
665 };
666 let new_cap = prev_cap.saturating_mul(2);
667 if new_cap <= prev_cap {
668 return Ok((assistant, allowlist));
669 }
670
671 max_tokens_attempt = max_tokens_attempt.saturating_add(1);
672 emit(
673 config,
674 AgentEvent::OutputTokensEscalation {
675 attempt: max_tokens_attempt,
676 prev_cap,
677 new_cap,
678 },
679 )
680 .await;
681 current_cap = Some(new_cap);
682 }
688}
689
690fn provider_retry_delay(base: std::time::Duration, attempt: u32) -> std::time::Duration {
691 base.saturating_mul(attempt.min(64))
692 .min(PROVIDER_RETRY_MAX_DELAY)
693}
694
695async fn stream_assistant_response(
696 context: &AgentContext,
697 config: &LoopConfig,
698 signal: &CancellationToken,
699 iteration: usize,
700 max_output_tokens: Option<u32>,
701 reasoning: ReasoningEffort,
702) -> Result<(AgentMessage, Option<std::collections::HashSet<String>>), LoopError> {
703 let last_provider_usage = last_provider_usage(&context.messages);
709 let cx = TransformContext {
710 signal,
711 model_id: config.model_id.as_deref().unwrap_or(""),
712 iteration,
713 last_provider_usage: last_provider_usage.as_ref(),
714 estimator: &*config.token_estimator,
715 };
716 let mut messages = context.messages.clone();
717 for transform in &config.plugins.context_transform {
724 if !transform.should_run(&messages, &cx) {
730 continue;
731 }
732 let before = messages.clone();
733 messages = transform.transform(messages, &cx).await;
734 emit(
735 config,
736 AgentEvent::ContextTransformApplied {
737 iteration,
738 plugin: transform.name(),
739 before,
740 after: messages.clone(),
741 },
742 )
743 .await;
744 }
745
746 let allowlist = collect_tool_allowlist_with_events(config, iteration, &messages).await;
751
752 let tools = build_tool_schemas(config, allowlist.as_ref());
753 emit(
758 config,
759 AgentEvent::ProviderRequestPrepared {
760 iteration,
761 model_id: config.model_id.clone(),
762 system_prompt: context.system_prompt.clone(),
763 messages: messages.clone(),
764 tools: tools.clone(),
765 temperature: config.temperature,
766 max_output_tokens,
767 },
768 )
769 .await;
770 let request = StreamRequest {
771 system_prompt: context.system_prompt.clone(),
772 messages,
773 tools,
774 temperature: config.temperature,
775 max_output_tokens,
776 reasoning,
777 provider_extras: config
778 .provider_extras
779 .clone()
780 .unwrap_or(serde_json::Value::Null),
781 force_tool_call: true,
790 };
791
792 let mut stream = config.stream.stream(request, signal.clone()).await;
793
794 let mut last_partial: Option<AgentMessage> = None;
795
796 while let Some(event) = stream.next().await {
797 match event {
798 StreamEvent::Start { partial } => {
799 emit(
800 config,
801 AgentEvent::MessageStart {
802 message: partial.clone(),
803 },
804 )
805 .await;
806 last_partial = Some(partial);
807 }
808 StreamEvent::Chunk(chunk) => {
809 if let Some(ref partial) = last_partial {
810 emit(
811 config,
812 AgentEvent::MessageUpdate {
813 partial: partial.clone(),
814 chunk,
815 },
816 )
817 .await;
818 }
819 }
820 StreamEvent::Done { message } => {
821 emit(
822 config,
823 AgentEvent::MessageEnd {
824 message: message.clone(),
825 },
826 )
827 .await;
828 return Ok((message, allowlist));
829 }
830 StreamEvent::Error {
831 partial,
832 kind,
833 message,
834 } => {
835 let stop_reason = match kind {
836 StreamErrorKind::Aborted => StopReason::Aborted,
837 _ => StopReason::Error,
838 };
839 let error_message = AgentMessage::Assistant {
840 content: match &partial {
841 AgentMessage::Assistant { content, .. } => content.clone(),
842 _ => AssistantContent { blocks: Vec::new() },
843 },
844 stop_reason,
845 error_message: Some(message.clone()),
846 timestamp: Some(now_ms()),
847 usage: None,
848 };
849 emit(
850 config,
851 AgentEvent::MessageEnd {
852 message: error_message.clone(),
853 },
854 )
855 .await;
856 return Err(loop_error_from_stream_kind(kind, message));
857 }
858 }
859 }
860
861 let empty = AgentMessage::Assistant {
864 content: AssistantContent { blocks: Vec::new() },
865 stop_reason: StopReason::Error,
866 error_message: Some("stream ended without terminal event".into()),
867 timestamp: Some(now_ms()),
868 usage: None,
869 };
870 emit(
871 config,
872 AgentEvent::MessageEnd {
873 message: empty.clone(),
874 },
875 )
876 .await;
877 Err(LoopError::Stream(StreamError::Empty))
878}
879
880fn context_with_zero_output_transport_recovery(context: &AgentContext) -> AgentContext {
881 let mut recovered = context.clone();
882 recovered.messages.push(AgentMessage::System {
883 content: ZERO_OUTPUT_TRANSPORT_RECOVERY_CONTEXT.to_string(),
884 timestamp: Some(now_ms()),
885 });
886 recovered
887}
888
889fn zero_output_transport_retry_reasoning(reasoning: ReasoningEffort) -> ReasoningEffort {
890 match reasoning {
891 ReasoningEffort::Medium | ReasoningEffort::High | ReasoningEffort::XHigh => {
892 ReasoningEffort::Minimal
893 }
894 ReasoningEffort::None | ReasoningEffort::Minimal | ReasoningEffort::Low => reasoning,
895 }
896}
897
898fn loop_error_from_stream_kind(kind: StreamErrorKind, message: String) -> LoopError {
899 match kind {
904 StreamErrorKind::Transient => LoopError::Stream(StreamError::Transient(message)),
905 StreamErrorKind::ProviderRateLimited => {
906 LoopError::Stream(StreamError::ProviderRateLimited(message))
907 }
908 StreamErrorKind::ZeroOutputTransport => {
909 LoopError::Stream(StreamError::ZeroOutputTransport(message))
910 }
911 StreamErrorKind::Fatal => LoopError::Stream(StreamError::Fatal(message)),
912 StreamErrorKind::InconsistentToolHistory => {
913 LoopError::Stream(StreamError::InconsistentToolHistory(message))
914 }
915 StreamErrorKind::Empty => LoopError::Stream(StreamError::Empty),
916 StreamErrorKind::Aborted => LoopError::Aborted,
917 StreamErrorKind::ContextOverflow => {
918 LoopError::Stream(StreamError::ContextOverflow(message))
919 }
920 }
921}
922
923fn now_ms() -> u64 {
924 SystemTime::now()
925 .duration_since(UNIX_EPOCH)
926 .map(|d| d.as_millis() as u64)
927 .unwrap_or(0)
928}
929
930fn last_provider_usage(messages: &[AgentMessage]) -> Option<Usage> {
934 messages.iter().rev().find_map(|message| match message {
935 AgentMessage::Assistant {
936 usage: Some(usage), ..
937 } => Some(usage.clone()),
938 _ => None,
939 })
940}
941
942fn build_tool_schemas(
943 config: &LoopConfig,
944 allowlist: Option<&std::collections::HashSet<String>>,
945) -> Vec<ToolSchema> {
946 config
947 .tools
948 .iter()
949 .filter(|tool| match allowlist {
950 Some(set) => set.contains(tool.name()),
951 None => true,
952 })
953 .map(|tool| ToolSchema {
954 name: tool.name().to_string(),
955 description: tool.description().to_string(),
956 parameters: tool.parameters_schema(),
957 })
958 .collect()
959}
960
961async fn collect_tool_allowlist_with_events(
973 config: &LoopConfig,
974 iteration: usize,
975 messages: &[AgentMessage],
976) -> Option<std::collections::HashSet<String>> {
977 if config.plugins.tool_gate.is_empty() {
978 return None;
979 }
980 let conversation_id = config.conversation_id.as_deref();
981 let available_tool_names: Vec<&str> = config.tools.iter().map(|t| t.name()).collect();
982 let mut decisions: Vec<GateAllowDecision> = Vec::new();
983 for gate in &config.plugins.tool_gate {
984 let ctx = crate::plugin::ToolGateContext {
985 iteration,
986 messages,
987 conversation_id,
988 available_tool_names: &available_tool_names,
989 };
990 let decision = gate.next_turn_tool_allowlist(ctx).await;
991 emit(
992 config,
993 AgentEvent::ToolGateApplied {
994 iteration,
995 plugin: gate.name(),
996 allow: decision.as_ref().map(|set| {
997 let mut sorted: Vec<String> = set.iter().cloned().collect();
998 sorted.sort();
999 sorted
1000 }),
1001 },
1002 )
1003 .await;
1004 if let Some(set) = decision {
1005 let suppresses_advisory =
1006 gate.suppresses_advisory_gates(crate::plugin::ToolGateContext {
1007 iteration,
1008 messages,
1009 conversation_id,
1010 available_tool_names: &available_tool_names,
1011 });
1012 decisions.push(GateAllowDecision {
1013 plugin: gate.name(),
1014 priority: gate.conflict_priority(),
1015 class: gate.tool_gate_class(),
1016 suppresses_advisory,
1017 allow: set,
1018 });
1019 }
1020 }
1021 let suppression_priority = decisions
1022 .iter()
1023 .filter(|decision| decision.suppresses_advisory)
1024 .map(|decision| decision.priority)
1025 .max();
1026 let active_decisions = decisions
1027 .iter()
1028 .filter(|decision| {
1029 !matches!(
1030 suppression_priority,
1031 Some(priority)
1032 if decision.class == crate::plugin::ToolGateClass::Advisory
1033 && decision.priority < priority
1034 )
1035 })
1036 .collect::<Vec<_>>();
1037 let mut combined: Option<std::collections::HashSet<String>> = None;
1038 for decision in &active_decisions {
1039 combined = Some(match combined {
1040 Some(prev) => prev.intersection(&decision.allow).cloned().collect(),
1041 None => decision.allow.clone(),
1042 });
1043 }
1044 if combined.as_ref().is_some_and(|allow| allow.is_empty()) {
1045 let non_empty_decisions = active_decisions
1046 .iter()
1047 .filter(|decision| !decision.allow.is_empty())
1048 .map(|decision| (decision.plugin, decision.priority, decision.allow.clone()))
1049 .collect::<Vec<_>>();
1050 let resolved = resolve_empty_tool_gate_intersection(&non_empty_decisions);
1051 let (chosen_plugin, allow, reason) = match resolved {
1052 Some((plugin, allow, reason)) => (Some(plugin.to_string()), allow, reason),
1053 None => (
1054 None,
1055 std::collections::HashSet::new(),
1056 "all gating plugins returned empty allowlists".to_string(),
1057 ),
1058 };
1059 let sorted_allow = sorted_tool_names(&allow);
1060 emit(
1061 config,
1062 AgentEvent::ToolGateConflictResolved {
1063 iteration,
1064 plugins: active_decisions
1065 .iter()
1066 .map(|decision| decision.plugin.to_string())
1067 .collect(),
1068 chosen_plugin,
1069 allow: sorted_allow,
1070 reason,
1071 },
1072 )
1073 .await;
1074 return if allow.is_empty() { None } else { Some(allow) };
1075 }
1076 combined
1077}
1078
1079struct GateAllowDecision {
1080 plugin: &'static str,
1081 priority: i32,
1082 class: crate::plugin::ToolGateClass,
1083 suppresses_advisory: bool,
1084 allow: std::collections::HashSet<String>,
1085}
1086
1087fn resolve_empty_tool_gate_intersection(
1088 decisions: &[(&'static str, i32, std::collections::HashSet<String>)],
1089) -> Option<(&'static str, std::collections::HashSet<String>, String)> {
1090 decisions
1091 .iter()
1092 .max_by(|(left_plugin, left_priority, left), (right_plugin, right_priority, right)| {
1093 left_priority
1094 .cmp(right_priority)
1095 .then_with(|| right.len().cmp(&left.len()))
1096 .then_with(|| right_plugin.cmp(left_plugin))
1097 })
1098 .map(|(plugin, priority, allow)| {
1099 (
1100 *plugin,
1101 allow.clone(),
1102 format!(
1103 "empty intersection repaired by highest-priority owner `{plugin}` (priority {priority})"
1104 ),
1105 )
1106 })
1107}
1108
1109fn sorted_tool_names(set: &std::collections::HashSet<String>) -> Vec<String> {
1110 let mut sorted: Vec<String> = set.iter().cloned().collect();
1111 sorted.sort();
1112 sorted
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117 use super::*;
1118 use crate::config::AgentBuilder;
1119 use crate::plugin::{
1120 FollowUpSource, Plugin, PluginCapabilities, ToolGate, ToolGateClass, ToolGateContext,
1121 };
1122 use crate::stream::{ReasoningEffort, StreamFn};
1123 use crate::types::{AssistantBlock, UserContent};
1124 use futures::stream::{self, BoxStream};
1125 use std::sync::{
1126 atomic::{AtomicUsize, Ordering},
1127 Arc, Mutex,
1128 };
1129
1130 #[test]
1131 fn inconsistent_tool_history_stream_kind_stays_typed_at_loop_boundary() {
1132 let error = loop_error_from_stream_kind(
1133 StreamErrorKind::InconsistentToolHistory,
1134 "interleaved tool result batch".into(),
1135 );
1136
1137 assert!(matches!(
1138 error,
1139 LoopError::Stream(StreamError::InconsistentToolHistory(message))
1140 if message == "interleaved tool result batch"
1141 ));
1142 }
1143
1144 fn empty_assistant_message() -> AgentMessage {
1145 AgentMessage::Assistant {
1146 content: AssistantContent { blocks: Vec::new() },
1147 stop_reason: StopReason::Other,
1148 error_message: None,
1149 timestamp: None,
1150 usage: None,
1151 }
1152 }
1153
1154 fn text_assistant_message(text: impl Into<String>) -> AgentMessage {
1155 AgentMessage::Assistant {
1156 content: AssistantContent::text(text),
1157 stop_reason: StopReason::EndTurn,
1158 error_message: None,
1159 timestamp: None,
1160 usage: None,
1161 }
1162 }
1163
1164 fn tool_call_assistant_message(name: impl Into<String>, id: impl Into<String>) -> AgentMessage {
1165 AgentMessage::Assistant {
1166 content: AssistantContent::with_tool_calls(
1167 None,
1168 vec![crate::tool::ToolCall {
1169 id: id.into(),
1170 name: name.into(),
1171 arguments: serde_json::json!({}),
1172 }],
1173 ),
1174 stop_reason: StopReason::ToolUse,
1175 error_message: None,
1176 timestamp: None,
1177 usage: None,
1178 }
1179 }
1180
1181 struct EmptyThenTextStream {
1182 calls: AtomicUsize,
1183 failures_before_success: usize,
1184 }
1185
1186 impl EmptyThenTextStream {
1187 fn new(failures_before_success: usize) -> Self {
1188 Self {
1189 calls: AtomicUsize::new(0),
1190 failures_before_success,
1191 }
1192 }
1193 }
1194
1195 #[derive(Default)]
1196 struct ZeroOutputThenTextStream {
1197 calls: AtomicUsize,
1198 requests: Mutex<Vec<StreamRequest>>,
1199 }
1200
1201 impl ZeroOutputThenTextStream {
1202 fn requests(&self) -> Vec<StreamRequest> {
1203 self.requests.lock().unwrap().clone()
1204 }
1205 }
1206
1207 #[derive(Default)]
1208 struct RepeatedTextStream {
1209 calls: AtomicUsize,
1210 }
1211
1212 struct PlainTextThenTerminatorStream {
1213 calls: AtomicUsize,
1214 plain_text_turns: usize,
1215 }
1216
1217 struct TerminalOnlyGate;
1218 struct TerminalWithStatusGate;
1219
1220 struct TestTerminalPolicy;
1226 impl crate::protocol::ProtocolPolicy for TestTerminalPolicy {
1227 fn terminal_tool_names(&self) -> std::collections::HashSet<String> {
1228 [
1229 "message_info",
1230 "message_ask",
1231 "message_result",
1232 "terminator",
1233 ]
1234 .iter()
1235 .map(|s| s.to_string())
1236 .collect()
1237 }
1238 }
1239 struct StaticAllowGate {
1240 name: &'static str,
1241 tools: &'static [&'static str],
1242 priority: i32,
1243 class: ToolGateClass,
1244 suppresses_advisory: bool,
1245 }
1246
1247 impl Plugin for TerminalOnlyGate {
1248 fn name(&self) -> &'static str {
1249 "terminal_only_gate"
1250 }
1251
1252 fn capabilities(&self) -> PluginCapabilities {
1253 PluginCapabilities::tool_gate()
1254 }
1255 }
1256
1257 #[async_trait::async_trait]
1258 impl ToolGate for TerminalOnlyGate {
1259 async fn next_turn_tool_allowlist(
1260 &self,
1261 _ctx: ToolGateContext<'_>,
1262 ) -> Option<std::collections::HashSet<String>> {
1263 Some(["message_result".to_string()].into_iter().collect())
1264 }
1265 }
1266
1267 impl Plugin for TerminalWithStatusGate {
1268 fn name(&self) -> &'static str {
1269 "terminal_with_status_gate"
1270 }
1271
1272 fn capabilities(&self) -> PluginCapabilities {
1273 PluginCapabilities::tool_gate()
1274 }
1275 }
1276
1277 #[async_trait::async_trait]
1278 impl ToolGate for TerminalWithStatusGate {
1279 async fn next_turn_tool_allowlist(
1280 &self,
1281 _ctx: ToolGateContext<'_>,
1282 ) -> Option<std::collections::HashSet<String>> {
1283 Some(
1284 ["message_info".to_string(), "message_result".to_string()]
1285 .into_iter()
1286 .collect(),
1287 )
1288 }
1289 }
1290
1291 impl Plugin for StaticAllowGate {
1292 fn name(&self) -> &'static str {
1293 self.name
1294 }
1295
1296 fn capabilities(&self) -> PluginCapabilities {
1297 PluginCapabilities::tool_gate()
1298 }
1299 }
1300
1301 #[async_trait::async_trait]
1302 impl ToolGate for StaticAllowGate {
1303 fn conflict_priority(&self) -> i32 {
1304 self.priority
1305 }
1306
1307 fn tool_gate_class(&self) -> ToolGateClass {
1308 self.class
1309 }
1310
1311 fn suppresses_advisory_gates(&self, _ctx: ToolGateContext<'_>) -> bool {
1312 self.suppresses_advisory
1313 }
1314
1315 async fn next_turn_tool_allowlist(
1316 &self,
1317 _ctx: ToolGateContext<'_>,
1318 ) -> Option<std::collections::HashSet<String>> {
1319 Some(self.tools.iter().map(|name| (*name).to_string()).collect())
1320 }
1321 }
1322
1323 impl PlainTextThenTerminatorStream {
1324 fn new(plain_text_turns: usize) -> Self {
1325 Self {
1326 calls: AtomicUsize::new(0),
1327 plain_text_turns,
1328 }
1329 }
1330 }
1331
1332 #[async_trait::async_trait]
1333 impl StreamFn for EmptyThenTextStream {
1334 async fn stream(
1335 &self,
1336 _request: StreamRequest,
1337 _signal: CancellationToken,
1338 ) -> BoxStream<'static, StreamEvent> {
1339 let call = self.calls.fetch_add(1, Ordering::SeqCst);
1340 let partial = empty_assistant_message();
1341 if call < self.failures_before_success {
1342 return Box::pin(stream::iter(vec![
1343 StreamEvent::Start {
1344 partial: partial.clone(),
1345 },
1346 StreamEvent::Error {
1347 partial,
1348 kind: StreamErrorKind::Empty,
1349 message: "empty provider response".to_string(),
1350 },
1351 ]));
1352 }
1353 Box::pin(stream::iter(vec![
1354 StreamEvent::Start { partial },
1355 StreamEvent::Done {
1356 message: text_assistant_message("recovered"),
1357 },
1358 ]))
1359 }
1360 }
1361
1362 #[async_trait::async_trait]
1363 impl StreamFn for RepeatedTextStream {
1364 async fn stream(
1365 &self,
1366 _request: StreamRequest,
1367 _signal: CancellationToken,
1368 ) -> BoxStream<'static, StreamEvent> {
1369 let call = self.calls.fetch_add(1, Ordering::SeqCst);
1370 let partial = empty_assistant_message();
1371 Box::pin(stream::iter(vec![
1372 StreamEvent::Start { partial },
1373 StreamEvent::Done {
1374 message: text_assistant_message(format!("plain stop {call}")),
1375 },
1376 ]))
1377 }
1378 }
1379
1380 #[async_trait::async_trait]
1381 impl StreamFn for PlainTextThenTerminatorStream {
1382 async fn stream(
1383 &self,
1384 _request: StreamRequest,
1385 _signal: CancellationToken,
1386 ) -> BoxStream<'static, StreamEvent> {
1387 let call = self.calls.fetch_add(1, Ordering::SeqCst);
1388 let partial = empty_assistant_message();
1389 let message = if call < self.plain_text_turns {
1390 text_assistant_message(format!("plain stop {call}"))
1391 } else if call == self.plain_text_turns {
1392 tool_call_assistant_message("terminator", "tc-terminator")
1393 } else {
1394 panic!("unexpected stream call after terminal turn: {call}")
1395 };
1396 Box::pin(stream::iter(vec![
1397 StreamEvent::Start { partial },
1398 StreamEvent::Done { message },
1399 ]))
1400 }
1401 }
1402
1403 #[async_trait::async_trait]
1404 impl StreamFn for ZeroOutputThenTextStream {
1405 async fn stream(
1406 &self,
1407 request: StreamRequest,
1408 _signal: CancellationToken,
1409 ) -> BoxStream<'static, StreamEvent> {
1410 self.requests.lock().unwrap().push(request);
1411 let call = self.calls.fetch_add(1, Ordering::SeqCst);
1412 let partial = empty_assistant_message();
1413 if call == 0 {
1414 return Box::pin(stream::iter(vec![
1415 StreamEvent::Start {
1416 partial: partial.clone(),
1417 },
1418 StreamEvent::Error {
1419 partial,
1420 kind: StreamErrorKind::ZeroOutputTransport,
1421 message: "response body decode failed before output".to_string(),
1422 },
1423 ]));
1424 }
1425 Box::pin(stream::iter(vec![
1426 StreamEvent::Start { partial },
1427 StreamEvent::Done {
1428 message: text_assistant_message("recovered from transport"),
1429 },
1430 ]))
1431 }
1432 }
1433
1434 struct OverflowThenTextStream {
1438 calls: AtomicUsize,
1439 requests: Mutex<Vec<StreamRequest>>,
1440 overflows_before_success: usize,
1441 }
1442
1443 impl OverflowThenTextStream {
1444 fn new(overflows_before_success: usize) -> Self {
1445 Self {
1446 calls: AtomicUsize::new(0),
1447 requests: Mutex::new(Vec::new()),
1448 overflows_before_success,
1449 }
1450 }
1451 }
1452
1453 impl Default for OverflowThenTextStream {
1454 fn default() -> Self {
1455 Self::new(1)
1456 }
1457 }
1458
1459 #[async_trait::async_trait]
1460 impl StreamFn for OverflowThenTextStream {
1461 async fn stream(
1462 &self,
1463 request: StreamRequest,
1464 _signal: CancellationToken,
1465 ) -> BoxStream<'static, StreamEvent> {
1466 self.requests.lock().unwrap().push(request);
1467 let call = self.calls.fetch_add(1, Ordering::SeqCst);
1468 let partial = empty_assistant_message();
1469 if call < self.overflows_before_success {
1470 return Box::pin(stream::iter(vec![
1471 StreamEvent::Start {
1472 partial: partial.clone(),
1473 },
1474 StreamEvent::Error {
1475 partial,
1476 kind: StreamErrorKind::ContextOverflow,
1477 message: "maximum context length exceeded".to_string(),
1478 },
1479 ]));
1480 }
1481 Box::pin(stream::iter(vec![
1482 StreamEvent::Start { partial },
1483 StreamEvent::Done {
1484 message: text_assistant_message("recovered after shrink"),
1485 },
1486 ]))
1487 }
1488 }
1489
1490 struct KeepLastRecovery {
1493 calls: Arc<AtomicUsize>,
1494 }
1495
1496 #[async_trait::async_trait]
1497 impl crate::plugin::ContextOverflowRecovery for KeepLastRecovery {
1498 async fn recover(
1499 &self,
1500 mut messages: Vec<AgentMessage>,
1501 _cx: &TransformContext<'_>,
1502 ) -> Vec<AgentMessage> {
1503 self.calls.fetch_add(1, Ordering::SeqCst);
1504 if !messages.is_empty() {
1505 messages.remove(0);
1506 }
1507 messages
1508 }
1509 fn name(&self) -> &'static str {
1510 "keep_last_recovery"
1511 }
1512 }
1513
1514 #[tokio::test]
1515 async fn context_overflow_is_recovered_by_shrinking_and_retrying() {
1516 let stream = Arc::new(OverflowThenTextStream::default());
1517 let recovery_calls = Arc::new(AtomicUsize::new(0));
1518 let config = AgentBuilder::new()
1519 .stream(stream.clone())
1520 .model_id("test-model")
1521 .overflow_recovery(KeepLastRecovery {
1522 calls: recovery_calls.clone(),
1523 })
1524 .build()
1525 .expect("config builds");
1526 let mut context = AgentContext::new("system").with_messages(vec![
1527 AgentMessage::User {
1528 content: UserContent::Text("first".to_string()),
1529 timestamp: None,
1530 },
1531 AgentMessage::User {
1532 content: UserContent::Text("keep me".to_string()),
1533 timestamp: None,
1534 },
1535 ]);
1536
1537 let (assistant, _allowlist) =
1538 stream_with_overflow_recovery(&mut context, &config, &CancellationToken::new(), 0)
1539 .await
1540 .expect("overflow recovery should retry");
1541
1542 let AgentMessage::Assistant { content, .. } = assistant else {
1543 panic!("expected assistant response");
1544 };
1545 assert_eq!(content.plain_text(), "recovered after shrink");
1546 assert_eq!(stream.calls.load(Ordering::SeqCst), 2, "one retry");
1547 assert_eq!(recovery_calls.load(Ordering::SeqCst), 1);
1548 assert_eq!(context.messages.len(), 1);
1550 let retried = &stream.requests.lock().unwrap()[1];
1552 assert_eq!(retried.messages.len(), 1);
1553 }
1554
1555 #[tokio::test]
1556 async fn context_overflow_recovery_outlives_the_legacy_two_attempt_cap() {
1557 let stream = Arc::new(OverflowThenTextStream::new(3));
1558 let recovery_calls = Arc::new(AtomicUsize::new(0));
1559 let config = AgentBuilder::new()
1560 .stream(stream.clone())
1561 .model_id("test-model")
1562 .overflow_recovery(KeepLastRecovery {
1563 calls: recovery_calls.clone(),
1564 })
1565 .build()
1566 .expect("config builds");
1567 let mut context = AgentContext::new("system").with_messages(
1568 ["first", "second", "third", "keep me"]
1569 .into_iter()
1570 .map(|text| AgentMessage::User {
1571 content: UserContent::Text(text.to_string()),
1572 timestamp: None,
1573 })
1574 .collect(),
1575 );
1576
1577 let (assistant, _allowlist) =
1578 stream_with_overflow_recovery(&mut context, &config, &CancellationToken::new(), 0)
1579 .await
1580 .expect("fourth stream attempt should recover");
1581
1582 let AgentMessage::Assistant { content, .. } = assistant else {
1583 panic!("expected assistant response");
1584 };
1585 assert_eq!(content.plain_text(), "recovered after shrink");
1586 assert_eq!(stream.calls.load(Ordering::SeqCst), 4);
1587 assert_eq!(recovery_calls.load(Ordering::SeqCst), 3);
1588 assert_eq!(context.messages.len(), 1);
1589 }
1590
1591 #[tokio::test]
1592 async fn context_overflow_without_recovery_propagates() {
1593 let stream = Arc::new(OverflowThenTextStream::default());
1594 let config = AgentBuilder::new()
1595 .stream(stream.clone())
1596 .model_id("test-model")
1597 .build()
1598 .expect("config builds");
1599 let mut context = AgentContext::new("system").with_messages(vec![AgentMessage::User {
1600 content: UserContent::Text("hi".to_string()),
1601 timestamp: None,
1602 }]);
1603
1604 let result =
1605 stream_with_overflow_recovery(&mut context, &config, &CancellationToken::new(), 0)
1606 .await;
1607 assert!(matches!(
1608 result,
1609 Err(LoopError::Stream(StreamError::ContextOverflow(_)))
1610 ));
1611 assert_eq!(stream.calls.load(Ordering::SeqCst), 1, "no retry");
1612 }
1613
1614 #[test]
1615 fn done_is_complete() {
1616 assert!(LoopOutcome::Done.is_complete());
1617 }
1618
1619 #[tokio::test(start_paused = true)]
1620 async fn empty_stream_recovery_outlives_the_legacy_three_attempt_cap() {
1621 let stream = Arc::new(EmptyThenTextStream::new(4));
1622 let config = AgentBuilder::new()
1623 .stream(stream.clone())
1624 .model_id("test-model")
1625 .build()
1626 .expect("config builds");
1627 let context = AgentContext::new("system").with_messages(vec![AgentMessage::User {
1628 content: UserContent::Text("continue".to_string()),
1629 timestamp: None,
1630 }]);
1631
1632 let (assistant, _allowlist) =
1633 stream_with_max_tokens_recovery(&context, &config, &CancellationToken::new(), 0)
1634 .await
1635 .expect("fifth stream attempt should recover");
1636
1637 let AgentMessage::Assistant { content, .. } = assistant else {
1638 panic!("expected assistant response");
1639 };
1640 assert_eq!(content.plain_text(), "recovered");
1641 assert_eq!(stream.calls.load(Ordering::SeqCst), 5);
1642 }
1643
1644 #[tokio::test]
1645 async fn zero_output_transport_error_is_retried_before_returning() {
1646 let stream = Arc::new(ZeroOutputThenTextStream::default());
1647 let config = AgentBuilder::new()
1648 .stream(stream.clone())
1649 .model_id("test-model")
1650 .reasoning(ReasoningEffort::High)
1651 .build()
1652 .expect("config builds");
1653 let context = AgentContext::new("system").with_messages(vec![AgentMessage::User {
1654 content: UserContent::Text("continue".to_string()),
1655 timestamp: None,
1656 }]);
1657
1658 let (assistant, _allowlist) =
1659 stream_with_max_tokens_recovery(&context, &config, &CancellationToken::new(), 0)
1660 .await
1661 .expect("second zero-output transport attempt should recover");
1662
1663 let AgentMessage::Assistant { content, .. } = assistant else {
1664 panic!("expected assistant response");
1665 };
1666 assert_eq!(content.plain_text(), "recovered from transport");
1667 assert_eq!(stream.calls.load(Ordering::SeqCst), 2);
1668
1669 let requests = stream.requests();
1670 assert_eq!(requests.len(), 2);
1671 assert_eq!(requests[0].reasoning, ReasoningEffort::High);
1672 assert_eq!(
1673 requests[1].reasoning,
1674 ReasoningEffort::Minimal,
1675 "zero-output replay should lower high reasoning so reasoning-heavy private-only spins can produce a tool call"
1676 );
1677 assert!(
1678 requests[1].messages.iter().any(|message| matches!(
1679 message,
1680 AgentMessage::System { content, .. }
1681 if content.contains("transport recovery")
1682 && content.contains("no visible assistant text")
1683 && content.contains("no usable tool call")
1684 && content.contains("unusable burst of partial tool calls")
1685 && content.contains("exactly one next structured tool call")
1686 && content.contains("next structured tool call")
1687 )),
1688 "zero-output replay must carry explicit recovery context"
1689 );
1690 }
1691
1692 struct TerminatorOnlyStream {
1696 calls: AtomicUsize,
1697 }
1698
1699 impl Default for TerminatorOnlyStream {
1700 fn default() -> Self {
1701 Self {
1702 calls: AtomicUsize::new(0),
1703 }
1704 }
1705 }
1706
1707 #[async_trait::async_trait]
1708 impl StreamFn for TerminatorOnlyStream {
1709 async fn stream(
1710 &self,
1711 _request: StreamRequest,
1712 _signal: CancellationToken,
1713 ) -> BoxStream<'static, StreamEvent> {
1714 let call = self.calls.fetch_add(1, Ordering::SeqCst);
1715 assert_eq!(
1716 call, 0,
1717 "terminate-on-turn-1 test must NOT re-enter the LLM after a successful terminator"
1718 );
1719 let partial = empty_assistant_message();
1720 let assistant = AgentMessage::Assistant {
1721 content: AssistantContent {
1722 blocks: vec![AssistantBlock::ToolCall(crate::tool::ToolCall {
1723 id: "tc-terminator-1".into(),
1724 name: "terminator".into(),
1725 arguments: serde_json::json!({}),
1726 })],
1727 },
1728 stop_reason: StopReason::ToolUse,
1729 error_message: None,
1730 timestamp: None,
1731 usage: None,
1732 };
1733 Box::pin(stream::iter(vec![
1734 StreamEvent::Start { partial },
1735 StreamEvent::Done { message: assistant },
1736 ]))
1737 }
1738 }
1739
1740 struct TerminatorTool;
1743
1744 #[async_trait::async_trait]
1745 impl crate::tool::AgentTool for TerminatorTool {
1746 fn name(&self) -> &str {
1747 "terminator"
1748 }
1749
1750 fn description(&self) -> &str {
1751 "test terminator"
1752 }
1753
1754 fn parameters_schema(&self) -> serde_json::Value {
1755 serde_json::json!({"type": "object"})
1756 }
1757
1758 async fn execute(
1759 &self,
1760 _call_id: &str,
1761 _args: serde_json::Value,
1762 _signal: CancellationToken,
1763 _update: tokio::sync::mpsc::UnboundedSender<crate::tool::ToolResult>,
1764 ) -> Result<crate::tool::ToolResult, crate::error::ToolError> {
1765 Ok(crate::tool::ToolResult {
1766 content: vec![crate::types::ToolResultBlock::Text(
1767 crate::types::TextContent {
1768 text: "delivered".into(),
1769 },
1770 )],
1771 is_error: false,
1772 details: serde_json::Value::Null,
1773 terminate: true,
1774 narration: None,
1775 })
1776 }
1777 }
1778
1779 struct AlwaysSteer {
1784 polls: Arc<AtomicUsize>,
1785 }
1786
1787 impl Plugin for AlwaysSteer {
1788 fn name(&self) -> &'static str {
1789 "always_steer"
1790 }
1791
1792 fn capabilities(&self) -> PluginCapabilities {
1793 PluginCapabilities {
1794 steering: true,
1795 ..PluginCapabilities::default()
1796 }
1797 }
1798 }
1799
1800 #[async_trait::async_trait]
1801 impl crate::plugin::SteeringSource for AlwaysSteer {
1802 async fn next_steering_messages(&self) -> Vec<AgentMessage> {
1803 self.polls.fetch_add(1, Ordering::SeqCst);
1804 vec![AgentMessage::System {
1805 content: "wrap up now".into(),
1806 timestamp: None,
1807 }]
1808 }
1809 }
1810
1811 #[tokio::test]
1812 async fn terminator_vote_skips_post_batch_steering_collection() {
1813 let stream = Arc::new(TerminatorOnlyStream::default());
1823 let polls = Arc::new(AtomicUsize::new(0));
1824 let mut tool_registry = crate::tool::ToolRegistry::new();
1825 tool_registry = tool_registry.with(Arc::new(TerminatorTool));
1826 let config = AgentBuilder::new()
1827 .stream(stream.clone())
1828 .model_id("test-model")
1829 .tools(tool_registry)
1830 .steering(AlwaysSteer {
1831 polls: polls.clone(),
1832 })
1833 .build()
1834 .expect("config builds");
1835 let context = AgentContext::new("system");
1836 let prompts = vec![AgentMessage::User {
1837 content: UserContent::Text("deliver".to_string()),
1838 timestamp: None,
1839 }];
1840
1841 let result = run(prompts, context, &config, CancellationToken::new())
1842 .await
1843 .expect("run completes after one terminator turn");
1844
1845 assert_eq!(stream.calls.load(Ordering::SeqCst), 1);
1847 assert_eq!(result.outcome, LoopOutcome::Done);
1849 assert_eq!(
1853 polls.load(Ordering::SeqCst),
1854 1,
1855 "steering source polled more than once — terminator vote did not gate post-batch re-entry"
1856 );
1857 }
1858
1859 struct AlwaysFollowUp {
1863 polls: Arc<AtomicUsize>,
1864 }
1865
1866 impl Plugin for AlwaysFollowUp {
1867 fn name(&self) -> &'static str {
1868 "always_follow_up"
1869 }
1870
1871 fn capabilities(&self) -> PluginCapabilities {
1872 PluginCapabilities::follow_up()
1873 }
1874 }
1875
1876 #[async_trait::async_trait]
1877 impl FollowUpSource for AlwaysFollowUp {
1878 async fn next_follow_up_messages(&self) -> Vec<AgentMessage> {
1879 self.polls.fetch_add(1, Ordering::SeqCst);
1880 vec![AgentMessage::System {
1881 content: "deliver something".into(),
1882 timestamp: None,
1883 }]
1884 }
1885 }
1886
1887 #[tokio::test]
1888 async fn terminator_vote_skips_post_batch_follow_up_collection() {
1889 let stream = Arc::new(TerminatorOnlyStream::default());
1895 let polls = Arc::new(AtomicUsize::new(0));
1896 let mut tool_registry = crate::tool::ToolRegistry::new();
1897 tool_registry = tool_registry.with(Arc::new(TerminatorTool));
1898 let config = AgentBuilder::new()
1899 .stream(stream.clone())
1900 .model_id("test-model")
1901 .tools(tool_registry)
1902 .follow_up(AlwaysFollowUp {
1903 polls: polls.clone(),
1904 })
1905 .build()
1906 .expect("config builds");
1907 let context = AgentContext::new("system");
1908 let prompts = vec![AgentMessage::User {
1909 content: UserContent::Text("deliver".to_string()),
1910 timestamp: None,
1911 }];
1912
1913 let result = run(prompts, context, &config, CancellationToken::new())
1914 .await
1915 .expect("run completes after one terminator turn");
1916
1917 assert_eq!(stream.calls.load(Ordering::SeqCst), 1);
1918 assert_eq!(result.outcome, LoopOutcome::Done);
1919 assert_eq!(
1920 polls.load(Ordering::SeqCst),
1921 0,
1922 "follow-up source polled after a terminator vote — terminator did not gate post-batch re-entry"
1923 );
1924 }
1925
1926 #[tokio::test]
1927 async fn empty_tool_gate_intersection_prefers_delivery_repair_owner() {
1928 let (sink, mut rx) = crate::event::ChannelSink::new();
1929 let config = AgentBuilder::new()
1930 .stream(Arc::new(RepeatedTextStream::default()))
1931 .event_sink(Arc::new(sink))
1932 .tool_gate_arc(Arc::new(StaticAllowGate {
1933 name: "delivery_repair_gate",
1934 tools: &["browser_interact"],
1935 priority: 100,
1936 class: ToolGateClass::Required,
1937 suppresses_advisory: false,
1938 }))
1939 .tool_gate_arc(Arc::new(StaticAllowGate {
1940 name: "terminal_message_guard",
1941 tools: &["message_result"],
1942 priority: 10,
1943 class: ToolGateClass::Required,
1944 suppresses_advisory: false,
1945 }))
1946 .build()
1947 .expect("config builds");
1948
1949 let allow = collect_tool_allowlist_with_events(&config, 3, &[])
1950 .await
1951 .expect("conflict repair should keep a non-empty allowlist");
1952
1953 assert_eq!(
1954 allow,
1955 ["browser_interact".to_string()].into_iter().collect()
1956 );
1957
1958 let mut saw_conflict = false;
1959 while let Ok(event) = rx.try_recv() {
1960 if let AgentEvent::ToolGateConflictResolved {
1961 chosen_plugin,
1962 allow,
1963 ..
1964 } = event
1965 {
1966 saw_conflict = true;
1967 assert_eq!(chosen_plugin.as_deref(), Some("delivery_repair_gate"));
1968 assert_eq!(allow, vec!["browser_interact".to_string()]);
1969 }
1970 }
1971 assert!(saw_conflict, "tool-gate deadlock should be diagnosable");
1972 }
1973
1974 #[tokio::test]
1975 async fn repair_owner_suppresses_advisory_gate_before_plan_only_intersection() {
1976 let config = AgentBuilder::new()
1977 .stream(Arc::new(RepeatedTextStream::default()))
1978 .tool_gate_arc(Arc::new(StaticAllowGate {
1979 name: "delivery_repair_gate",
1980 tools: &["plan", "file_write"],
1981 priority: 100,
1982 class: ToolGateClass::Required,
1983 suppresses_advisory: true,
1984 }))
1985 .tool_gate_arc(Arc::new(StaticAllowGate {
1986 name: "wrap_up_gate",
1987 tools: &["plan", "message_result", "message_ask"],
1988 priority: 0,
1989 class: ToolGateClass::Advisory,
1990 suppresses_advisory: false,
1991 }))
1992 .build()
1993 .expect("config builds");
1994
1995 let allow = collect_tool_allowlist_with_events(&config, 3, &[])
1996 .await
1997 .expect("repair owner should keep its own allowlist");
1998
1999 assert_eq!(
2000 allow,
2001 ["plan".to_string(), "file_write".to_string()]
2002 .into_iter()
2003 .collect()
2004 );
2005 }
2006
2007 #[tokio::test]
2008 async fn terminal_only_plain_text_fallback_synthesizes_terminal_result() {
2009 let stream = Arc::new(RepeatedTextStream::default());
2010 let mut tool_registry = crate::tool::ToolRegistry::new();
2011 tool_registry = tool_registry.with(Arc::new(TerminalNamedTool("message_result")));
2012 let config = AgentBuilder::new()
2013 .stream(stream.clone())
2014 .model_id("auto-tool-provider")
2015 .tools(tool_registry)
2016 .tool_gate_arc(Arc::new(TerminalOnlyGate))
2017 .plain_text_terminal_fallback_tool("message_result")
2018 .build()
2019 .expect("config builds");
2020 let context = AgentContext::new("system");
2021 let prompts = vec![AgentMessage::User {
2022 content: UserContent::Text("answer directly".to_string()),
2023 timestamp: None,
2024 }];
2025
2026 let result = run(prompts, context, &config, CancellationToken::new())
2027 .await
2028 .expect("plain text should be converted on terminal-only turn");
2029
2030 assert_eq!(stream.calls.load(Ordering::SeqCst), 1);
2031 assert_eq!(result.outcome, LoopOutcome::Done);
2032 assert!(result.messages.iter().any(|message| matches!(
2033 message,
2034 AgentMessage::ToolResult {
2035 tool_name,
2036 content,
2037 is_error: false,
2038 ..
2039 } if tool_name == "message_result"
2040 && content.plain_text() == "plain stop 0"
2041 )));
2042 }
2043
2044 #[tokio::test]
2045 async fn eager_plain_text_fallback_fires_without_terminal_only_allowlist() {
2046 let stream = Arc::new(RepeatedTextStream::default());
2057 let mut tool_registry = crate::tool::ToolRegistry::new();
2058 tool_registry = tool_registry.with(Arc::new(TerminalNamedTool("message_result")));
2059 let config = AgentBuilder::new()
2060 .stream(stream.clone())
2061 .model_id("auto-tool-provider-eager")
2062 .tools(tool_registry)
2063 .plain_text_terminal_fallback_tool("message_result")
2064 .plain_text_terminal_fallback_eager(true)
2065 .build()
2066 .expect("config builds");
2067 let context = AgentContext::new("system");
2068 let prompts = vec![AgentMessage::User {
2069 content: UserContent::Text("answer directly".to_string()),
2070 timestamp: None,
2071 }];
2072
2073 let result = run(prompts, context, &config, CancellationToken::new())
2074 .await
2075 .expect("eager fallback should convert plain text on first stop");
2076
2077 assert_eq!(stream.calls.load(Ordering::SeqCst), 1);
2078 assert_eq!(result.outcome, LoopOutcome::Done);
2079 assert!(result.messages.iter().any(|message| matches!(
2080 message,
2081 AgentMessage::ToolResult {
2082 tool_name,
2083 content,
2084 is_error: false,
2085 ..
2086 } if tool_name == "message_result"
2087 && content.plain_text() == "plain stop 0"
2088 )));
2089 }
2090
2091 #[tokio::test]
2092 async fn eager_nudge_mode_has_no_implicit_retry_ceiling() {
2093 let stream = Arc::new(PlainTextThenTerminatorStream::new(4));
2099 let mut tool_registry = crate::tool::ToolRegistry::new();
2100 tool_registry = tool_registry
2101 .with(Arc::new(TerminalNamedTool("message_result")))
2102 .with(Arc::new(TerminatorTool));
2103 let config = AgentBuilder::new()
2104 .stream(stream.clone())
2105 .model_id("auto-tool-provider-eager-nudge")
2106 .tools(tool_registry)
2107 .plain_text_terminal_fallback_tool("message_result")
2108 .plain_text_terminal_fallback_eager(true)
2109 .plain_text_terminal_fallback_eager_nudge(true)
2110 .build()
2111 .expect("config builds");
2112 let context = AgentContext::new("system");
2113 let prompts = vec![AgentMessage::User {
2114 content: UserContent::Text("answer directly".to_string()),
2115 timestamp: None,
2116 }];
2117
2118 let result = run(prompts, context, &config, CancellationToken::new())
2119 .await
2120 .expect("nudge mode should wait for a real terminal tool call");
2121
2122 assert_eq!(stream.calls.load(Ordering::SeqCst), 5);
2123 assert_eq!(result.outcome, LoopOutcome::Done);
2124
2125 let nudge_count = result
2126 .messages
2127 .iter()
2128 .filter(|m| matches!(m, AgentMessage::System { content, .. } if content == crate::protocol::DEFAULT_PLAIN_TEXT_RECOVERY_PROMPT))
2129 .count();
2130 assert_eq!(
2131 nudge_count, 4,
2132 "expected one protocol-recovery message per plain-text turn, got {nudge_count}",
2133 );
2134 }
2135
2136 #[tokio::test]
2137 async fn non_eager_plain_text_fallback_still_requires_narrowed_allowlist() {
2138 let stream = Arc::new(RepeatedTextStream::default());
2142 let mut tool_registry = crate::tool::ToolRegistry::new();
2143 tool_registry = tool_registry.with(Arc::new(TerminalNamedTool("message_result")));
2144 let config = AgentBuilder::new()
2145 .stream(stream.clone())
2146 .model_id("non-eager-provider")
2147 .tools(tool_registry)
2148 .plain_text_terminal_fallback_tool("message_result")
2149 .build()
2151 .expect("config builds");
2152 let context = AgentContext::new("system");
2153 let prompts = vec![AgentMessage::User {
2154 content: UserContent::Text("answer directly".to_string()),
2155 timestamp: None,
2156 }];
2157
2158 let result = run(prompts, context, &config, CancellationToken::new())
2159 .await
2160 .expect("plain assistant completion should remain valid");
2161
2162 assert_eq!(stream.calls.load(Ordering::SeqCst), 1);
2163 assert!(!result.messages.iter().any(|message| matches!(
2164 message,
2165 AgentMessage::ToolResult { tool_name, .. } if tool_name == "message_result"
2166 )));
2167 }
2168
2169 #[tokio::test]
2170 async fn terminal_plain_text_fallback_allows_status_delivery_gate() {
2171 let stream = Arc::new(RepeatedTextStream::default());
2172 let mut tool_registry = crate::tool::ToolRegistry::new();
2173 tool_registry = tool_registry.with(Arc::new(TerminalNamedTool("message_result")));
2174 let config = AgentBuilder::new()
2175 .stream(stream.clone())
2176 .model_id("auto-tool-provider")
2177 .tools(tool_registry)
2178 .protocol_policy(Arc::new(TestTerminalPolicy))
2179 .tool_gate_arc(Arc::new(TerminalWithStatusGate))
2180 .plain_text_terminal_fallback_tool("message_result")
2181 .build()
2182 .expect("config builds");
2183 let context = AgentContext::new("system");
2184 let prompts = vec![AgentMessage::User {
2185 content: UserContent::Text("answer directly".to_string()),
2186 timestamp: None,
2187 }];
2188
2189 let result = run(prompts, context, &config, CancellationToken::new())
2190 .await
2191 .expect(
2192 "plain text should be converted when only status and terminal tools are allowed",
2193 );
2194
2195 assert_eq!(stream.calls.load(Ordering::SeqCst), 1);
2196 assert_eq!(result.outcome, LoopOutcome::Done);
2197 assert!(result.messages.iter().any(|message| matches!(
2198 message,
2199 AgentMessage::ToolResult {
2200 tool_name,
2201 content,
2202 is_error: false,
2203 ..
2204 } if tool_name == "message_result"
2205 && content.plain_text() == "plain stop 0"
2206 )));
2207 }
2208
2209 struct TerminalNamedTool(&'static str);
2210
2211 #[async_trait::async_trait]
2212 impl crate::tool::AgentTool for TerminalNamedTool {
2213 fn name(&self) -> &str {
2214 self.0
2215 }
2216
2217 fn description(&self) -> &str {
2218 "test terminal tool"
2219 }
2220
2221 fn parameters_schema(&self) -> serde_json::Value {
2222 serde_json::json!({"type": "object"})
2223 }
2224
2225 async fn execute(
2226 &self,
2227 _call_id: &str,
2228 _args: serde_json::Value,
2229 _signal: CancellationToken,
2230 _update: tokio::sync::mpsc::UnboundedSender<crate::tool::ToolResult>,
2231 ) -> Result<crate::tool::ToolResult, crate::error::ToolError> {
2232 Ok(crate::tool::ToolResult {
2233 content: vec![crate::types::ToolResultBlock::Text(
2234 crate::types::TextContent {
2235 text: "not used".into(),
2236 },
2237 )],
2238 is_error: false,
2239 details: serde_json::Value::Null,
2240 terminate: true,
2241 narration: None,
2242 })
2243 }
2244 }
2245}