1use async_trait::async_trait;
2use futures::{stream::StreamExt, FutureExt};
3use serde_json::{json, Value};
4use std::sync::Arc;
5use std::time::Duration;
6use tokio::sync::mpsc;
7use tokio_util::sync::CancellationToken;
8
9use crate::compaction::{
10 estimate_messages_tokens, CompactionContext, CompactionStrategy, SummarizeCompactionStrategy,
11};
12use crate::event::{HarnessInternalEvent, HarnessUsage, NativeHarnessError, NativeTurnInput};
13use crate::model::{
14 AssistantThinking, CapabilitySupport, ChatMessage, HostedCapability, HostedTool, ModelChunk,
15 ModelClient, ModelClientError, ModelTurnInput,
16};
17use crate::runner::NativeHarness;
18use crate::tools::{
19 bounded::BoundedToolRuntime, ToolFailure, ToolFailureKind, ToolInvocation, ToolOutcome,
20 ToolRuntime, ToolRuntimeError, ToolSpec,
21};
22
23#[derive(Clone)]
29pub struct CompactionPolicy {
30 pub strategy: Arc<dyn CompactionStrategy>,
31 pub model_client: Arc<dyn ModelClient>,
35 pub context_window_tokens: u64,
36}
37
38impl CompactionPolicy {
39 pub fn new(
46 strategy: Arc<dyn CompactionStrategy>,
47 model_client: Arc<dyn ModelClient>,
48 context_window_tokens: u64,
49 ) -> Self {
50 Self {
51 strategy,
52 model_client,
53 context_window_tokens,
54 }
55 }
56
57 pub fn summarizing(model_client: Arc<dyn ModelClient>, context_window_tokens: u64) -> Self {
63 Self::new(
64 Arc::new(SummarizeCompactionStrategy::default()),
65 model_client,
66 context_window_tokens,
67 )
68 }
69}
70
71const DEFAULT_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
77
78const DEFAULT_STREAM_MAX_ATTEMPTS: u32 = 6;
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum WebSearchMode {
90 #[default]
92 Off,
93 Auto,
96 Native,
99 Managed,
101}
102
103#[derive(Clone)]
104pub struct AgentLoopHarness<M, R> {
105 model: M,
106 tools: BoundedToolRuntime<R>,
110 max_steps: usize,
111 compaction: Option<CompactionPolicy>,
112 tool_choice: crate::model::ToolChoice,
113 web_search: WebSearchMode,
114 parallel_tool_calls: Option<bool>,
115 stream_idle_timeout: Duration,
116 stream_max_attempts: u32,
117}
118
119impl<M, R: ToolRuntime> AgentLoopHarness<M, R> {
120 pub fn new(model: M, tools: R) -> Self {
121 Self {
122 model,
123 tools: BoundedToolRuntime::new(tools),
124 max_steps: 8,
125 compaction: None,
126 tool_choice: crate::model::ToolChoice::Auto,
127 web_search: WebSearchMode::Off,
128 parallel_tool_calls: None,
129 stream_idle_timeout: DEFAULT_STREAM_IDLE_TIMEOUT,
130 stream_max_attempts: DEFAULT_STREAM_MAX_ATTEMPTS,
131 }
132 }
133
134 pub fn with_max_steps(mut self, max_steps: usize) -> Self {
139 self.max_steps = max_steps;
140 self
141 }
142
143 pub fn with_compaction(mut self, policy: CompactionPolicy) -> Self {
149 self.compaction = Some(policy);
150 self
151 }
152
153 pub fn with_tool_choice(mut self, choice: crate::model::ToolChoice) -> Self {
156 self.tool_choice = choice;
157 self
158 }
159
160 pub fn with_web_search(mut self, mode: WebSearchMode) -> Self {
163 self.web_search = mode;
164 self
165 }
166
167 pub fn with_parallel_tool_calls(mut self, parallel: Option<bool>) -> Self {
171 self.parallel_tool_calls = parallel;
172 self
173 }
174
175 pub fn with_stream_resilience(mut self, idle_timeout: Duration, max_attempts: u32) -> Self {
182 self.stream_idle_timeout = idle_timeout;
183 self.stream_max_attempts = max_attempts.max(1);
184 self
185 }
186}
187
188#[async_trait]
189impl<M, R> NativeHarness for AgentLoopHarness<M, R>
190where
191 M: ModelClient + Clone + Send + Sync + 'static,
192 R: ToolRuntime + Clone + Send + Sync + 'static,
193{
194 async fn run_turn(
195 &self,
196 input: NativeTurnInput,
197 ) -> Result<mpsc::Receiver<Result<HarnessInternalEvent, NativeHarnessError>>, NativeHarnessError>
198 {
199 let (tx, rx) = mpsc::channel(16);
200 let model = self.model.clone();
201 let tools = self.tools.clone();
202 let max_steps = self.max_steps;
203 let compaction = self.compaction.clone();
204 let tool_choice = self.tool_choice.clone();
205 let web_search = self.web_search;
206 let parallel_tool_calls = self.parallel_tool_calls;
207 let stream_idle_timeout = self.stream_idle_timeout;
208 let stream_max_attempts = self.stream_max_attempts;
209
210 tokio::spawn(async move {
211 let tx_for_panic = tx.clone();
212 let result = std::panic::AssertUnwindSafe(run_loop(
213 model,
214 tools,
215 RunLoopConfig {
216 max_steps,
217 compaction,
218 tool_choice,
219 web_search,
220 parallel_tool_calls,
221 stream_idle_timeout,
222 stream_max_attempts,
223 },
224 input,
225 tx,
226 ))
227 .catch_unwind()
228 .await;
229 if let Err(payload) = result {
230 let detail = panic_payload_to_string(payload.as_ref());
231 tracing::error!(
232 target: "harness::agent_loop",
233 panic = %detail,
234 "native agent loop panicked"
235 );
236 let _ = tx_for_panic
237 .send(Err(NativeHarnessError::Failed(format!(
238 "agent loop panicked: {detail}"
239 ))))
240 .await;
241 }
242 });
243
244 Ok(rx)
245 }
246}
247
248fn cancel_fired(token: Option<&CancellationToken>) -> bool {
250 token.is_some_and(|t| t.is_cancelled())
251}
252
253fn is_silent_stop(text: &str, stop_reason: &str) -> bool {
254 text.trim().is_empty() && matches!(stop_reason, "end_turn" | "max_tokens")
255}
256
257struct RunLoopConfig {
258 max_steps: usize,
259 compaction: Option<CompactionPolicy>,
260 tool_choice: crate::model::ToolChoice,
261 web_search: WebSearchMode,
262 parallel_tool_calls: Option<bool>,
263 stream_idle_timeout: Duration,
264 stream_max_attempts: u32,
265}
266
267fn resolve_web_search_tools(
268 mode: WebSearchMode,
269 native_support: CapabilitySupport,
270 mut tools: Vec<ToolSpec>,
271) -> Result<(Vec<ToolSpec>, Vec<HostedTool>), String> {
272 let has_managed = tools.iter().any(|tool| tool.name == "web_search");
273 let remove_managed = |tools: &mut Vec<ToolSpec>| {
274 tools.retain(|tool| tool.name != "web_search");
275 };
276
277 match mode {
278 WebSearchMode::Off => {
279 remove_managed(&mut tools);
280 Ok((tools, vec![]))
281 }
282 WebSearchMode::Auto if native_support == CapabilitySupport::Supported => {
283 remove_managed(&mut tools);
284 Ok((tools, vec![HostedTool::WebSearch]))
285 }
286 WebSearchMode::Auto if has_managed => Ok((tools, vec![])),
287 WebSearchMode::Auto => Ok((tools, vec![])),
288 WebSearchMode::Native if native_support == CapabilitySupport::Unsupported => Err(
289 "web search mode is Native, but the model client does not support hosted web search"
290 .into(),
291 ),
292 WebSearchMode::Native => {
293 remove_managed(&mut tools);
294 Ok((tools, vec![HostedTool::WebSearch]))
295 }
296 WebSearchMode::Managed if !has_managed => Err(
297 "web search mode is Managed, but the tool runtime does not provide `web_search`".into(),
298 ),
299 WebSearchMode::Managed => Ok((tools, vec![])),
300 }
301}
302
303async fn run_loop<M, R>(
304 model: M,
305 tools: R,
306 config: RunLoopConfig,
307 input: NativeTurnInput,
308 tx: mpsc::Sender<Result<HarnessInternalEvent, NativeHarnessError>>,
309) where
310 M: ModelClient + Send + Sync,
311 R: ToolRuntime + Clone + Send + Sync + 'static,
312{
313 let system_prompt = input.system_prompt.clone();
314 let cancel_token = input.cancel_token.clone();
315 let context_path = input.context_path.clone();
316 let (tools_snapshot, hosted_tools) = match resolve_web_search_tools(
320 config.web_search,
321 model.hosted_capability(HostedCapability::WebSearch),
322 tools.specs(),
323 ) {
324 Ok(selection) => selection,
325 Err(message) => {
326 let _ = tx
327 .send(Err(NativeHarnessError::ModelBadRequest(message)))
328 .await;
329 return;
330 }
331 };
332 let mut messages: Vec<ChatMessage> = if let Some(ref path) = context_path {
335 crate::context::jsonl::load_context(path).await
336 } else {
337 input.prior_messages
338 };
339 messages.push(ChatMessage::User {
340 content: input.prompt_text,
341 attachments: input.attachments,
342 });
343 let mut ctx_written: usize = match context_path.as_deref() {
347 None => 0,
348 Some(path) => {
349 let start = messages.len() - 1;
350 crate::context::jsonl::append_context(path, &messages[start..]).await;
351 messages.len()
352 }
353 };
354 let mut total_usage = HarnessUsage::default();
358 let mut saw_any_usage = false;
359
360 macro_rules! check_cancel {
366 () => {
367 if cancel_fired(cancel_token.as_ref()) {
368 let _ = tx
369 .send(Ok(HarnessInternalEvent::TurnEnd {
370 stop_reason: "interrupt".into(),
371 usage: saw_any_usage.then(|| total_usage.clone()),
372 final_messages: if context_path.is_none() {
373 messages.clone()
374 } else {
375 vec![]
376 },
377 }))
378 .await;
379 return;
380 }
381 };
382 }
383
384 for step in 0.. {
385 if config.max_steps != 0 && step >= config.max_steps {
389 break;
390 }
391 check_cancel!();
392 if let Some(policy) = &config.compaction {
398 if policy
399 .strategy
400 .should_compact(&messages, policy.context_window_tokens)
401 {
402 let original_count = messages.len();
403 let original_tokens = estimate_messages_tokens(&messages);
404 let cctx = CompactionContext {
405 system_prompt: system_prompt.clone(),
406 model_client: policy.model_client.clone(),
407 context_window_tokens: policy.context_window_tokens,
408 tools: tools_snapshot.clone(),
409 };
410 match policy.strategy.compact(messages.clone(), &cctx).await {
411 Ok(outcome) => {
412 let compacted_count = outcome.messages.len();
413 let compacted_tokens = estimate_messages_tokens(&outcome.messages);
414 messages = outcome.messages;
415 if let Some(u) = outcome.usage.as_ref() {
421 saw_any_usage = true;
422 total_usage.input_tokens += u.input_tokens;
423 total_usage.output_tokens += u.output_tokens;
424 total_usage.cache_read_input_tokens += u.cache_read_input_tokens;
425 total_usage.cache_creation_input_tokens +=
426 u.cache_creation_input_tokens;
427 total_usage.compaction_input_tokens += u.input_tokens;
428 total_usage.compaction_output_tokens += u.output_tokens;
429 }
430 tracing::info!(
434 target: "harness::compaction",
435 step,
436 original_message_count = original_count,
437 compacted_message_count = compacted_count,
438 original_estimated_tokens = original_tokens,
439 compacted_estimated_tokens = compacted_tokens,
440 context_window_tokens = policy.context_window_tokens,
441 "compaction applied"
442 );
443 if let Some(ref path) = context_path {
445 crate::context::jsonl::rewrite_context(path, &messages).await;
446 ctx_written = messages.len();
447 }
448 if tx
449 .send(Ok(HarnessInternalEvent::CompactionApplied {
450 original_message_count: original_count,
451 compacted_message_count: compacted_count,
452 original_tokens,
453 compacted_tokens,
454 }))
455 .await
456 .is_err()
457 {
458 return;
459 }
460 }
461 Err(e) => {
462 tracing::warn!(
463 target: "harness::compaction",
464 step,
465 error = %e,
466 "compaction skipped; history retained as-is, model call may now fail with context overflow"
467 );
468 }
469 }
470 }
471 }
472
473 const MAX_RETRIES: u32 = 3;
478 const BASE_BACKOFF_MS: u64 = 1_000;
479 const MAX_BACKOFF_MS: u64 = 16_000;
480
481 let model_input = ModelTurnInput {
482 system_prompt: system_prompt.clone(),
483 messages: messages.clone(),
484 tools: tools_snapshot.clone(),
485 hosted_tools: hosted_tools.clone(),
486 tool_choice: config.tool_choice.clone(),
487 parallel_tool_calls: config.parallel_tool_calls,
488 };
489
490 let mut stream_attempt = 0u32;
500 let outcome = 'stream: loop {
501 let stream = {
502 let mut attempt = 0u32;
503 loop {
504 match model.stream(model_input.clone()).await {
505 Ok(s) => break s,
506 Err(e) => {
507 if e.retryable() && attempt < MAX_RETRIES {
508 let delay_ms =
509 (BASE_BACKOFF_MS * (1 << attempt)).min(MAX_BACKOFF_MS);
510 tracing::warn!(
511 attempt,
512 delay_ms,
513 error = %e,
514 "model call failed (retryable) — backing off"
515 );
516 if !backoff_sleep(delay_ms, cancel_token.as_ref()).await {
517 let _ = tx
518 .send(Err(NativeHarnessError::ModelOther(
519 "interrupted during retry backoff".into(),
520 )))
521 .await;
522 return;
523 }
524 attempt += 1;
525 } else {
526 tracing::error!(
529 attempt,
530 error = %e,
531 retryable = e.retryable(),
532 "model call failed — terminating turn"
533 );
534 let _ = tx.send(Err(model_error_to_native(e))).await;
535 return;
536 }
537 }
538 }
539 }
540 };
541
542 match consume_step_stream(
547 stream,
548 &tx,
549 step,
550 cancel_token.as_ref(),
551 config.stream_idle_timeout,
552 )
553 .await
554 {
555 Ok(StepDrain::Complete(o)) => break 'stream o,
556 Ok(StepDrain::Cancelled) => {
557 let _ = tx
558 .send(Ok(HarnessInternalEvent::TurnEnd {
559 stop_reason: "interrupt".into(),
560 usage: saw_any_usage.then(|| total_usage.clone()),
561 final_messages: if context_path.is_none() {
562 messages.clone()
563 } else {
564 vec![]
565 },
566 }))
567 .await;
568 return;
569 }
570 Err(StepFailure::Model { err, had_progress }) => {
571 if !had_progress
574 && err.retryable()
575 && stream_attempt + 1 < config.stream_max_attempts
576 {
577 let delay_ms =
578 (BASE_BACKOFF_MS * (1 << stream_attempt)).min(MAX_BACKOFF_MS);
579 tracing::warn!(
580 step,
581 stream_attempt,
582 delay_ms,
583 error = %err,
584 "model stream failed before any output — reconnecting"
585 );
586 if !backoff_sleep(delay_ms, cancel_token.as_ref()).await {
587 let _ = tx
588 .send(Err(NativeHarnessError::ModelOther(
589 "interrupted during stream reconnect backoff".into(),
590 )))
591 .await;
592 return;
593 }
594 stream_attempt += 1;
595 continue 'stream;
596 }
597 tracing::error!(
600 step,
601 stream_attempt,
602 error = %err,
603 had_progress,
604 retryable = err.retryable(),
605 "model stream failed — terminating turn"
606 );
607 let _ = tx.send(Err(model_error_to_native(err))).await;
608 return;
609 }
610 Err(StepFailure::ChannelClosed) => return,
611 Err(StepFailure::Fatal(e)) => {
612 let _ = tx.send(Err(e)).await;
613 return;
614 }
615 }
616 };
617
618 if let Some(u) = outcome.usage.as_ref() {
619 saw_any_usage = true;
620 total_usage.input_tokens += u.input_tokens;
621 total_usage.output_tokens += u.output_tokens;
622 total_usage.cache_read_input_tokens += u.cache_read_input_tokens;
623 total_usage.cache_creation_input_tokens += u.cache_creation_input_tokens;
624 }
625
626 match outcome.next {
627 StepNext::Message { text, stop_reason } => {
628 if is_silent_stop(&text, &stop_reason) {
629 let _ = tx
630 .send(Err(NativeHarnessError::ModelOther(format!(
631 "silent_stop: model returned stop_reason={stop_reason} \
632 with empty text and no tool calls"
633 ))))
634 .await;
635 return;
636 }
637 let assistant_text = (!text.trim().is_empty()).then_some(text);
638 messages.push(ChatMessage::Assistant {
639 text: assistant_text,
640 tool_calls: vec![],
641 thinking: outcome.thinking.clone(),
642 usage: outcome.usage.clone(),
643 });
644 if let Some(ref path) = context_path {
646 crate::context::jsonl::append_context(path, &messages[ctx_written..]).await;
647 }
648 let final_msgs = if context_path.is_none() {
651 messages.clone()
652 } else {
653 vec![]
654 };
655 let _ = tx
656 .send(Ok(HarnessInternalEvent::TurnEnd {
657 stop_reason,
658 usage: saw_any_usage.then(|| total_usage.clone()),
659 final_messages: final_msgs,
660 }))
661 .await;
662 return;
663 }
664 StepNext::ToolCalls {
665 preface,
666 mut invocations,
667 } => {
668 check_cancel!();
669 for inv in &mut invocations {
677 if let Some(repairs) = tools.repair_invocation(inv) {
682 inv.raw_emitted_args = None;
683 tracing::warn!(
684 target: "harness::tool_repair",
685 tool = %inv.name,
686 id = %inv.id,
687 repairs = ?repairs,
688 "schema-guided tool input repair applied"
689 );
690 }
691 }
692 let preface_text = preface.filter(|s| !s.is_empty());
693 messages.push(ChatMessage::Assistant {
701 text: preface_text,
702 tool_calls: invocations.clone(),
703 thinking: outcome.thinking.clone(),
704 usage: outcome.usage.clone(),
705 });
706
707 for inv in &invocations {
713 if tx
714 .send(Ok(HarnessInternalEvent::ToolCall {
715 id: inv.id.clone(),
716 name: inv.name.clone(),
717 input: inv.input.clone(),
718 }))
719 .await
720 .is_err()
721 {
722 return;
723 }
724 }
725
726 let calls = invocations.iter().cloned().map(|inv| {
732 let tools = tools.clone();
733 let cancel_for_task = cancel_token.clone();
734 let invocation_for_task = inv.clone();
735 async move {
736 let call =
737 tools.invoke_cancellable(invocation_for_task, cancel_for_task.as_ref());
738 let outcome = match std::panic::AssertUnwindSafe(call).catch_unwind().await
739 {
740 Ok(outcome) => outcome,
741 Err(payload) => Err(ToolRuntimeError::Runtime(format!(
742 "tool task panicked: {}",
743 panic_payload_to_string(payload.as_ref())
744 ))),
745 };
746 (inv, outcome)
747 }
748 });
749 let join = futures::future::join_all(calls);
750 tokio::pin!(join);
751
752 let pairs_opt = if let Some(token) = cancel_token.as_ref() {
753 tokio::select! {
754 biased;
755 _ = token.cancelled() => {
756 let _ = tokio::time::timeout(
761 Duration::from_secs(1),
762 &mut join,
763 ).await;
764 None
765 },
766 results = &mut join => Some(results),
767 }
768 } else {
769 Some((&mut join).await)
770 };
771 let pairs = match pairs_opt {
772 Some(o) => o,
773 None => {
774 let _ = tx
778 .send(Ok(HarnessInternalEvent::TurnEnd {
779 stop_reason: "interrupt".into(),
780 usage: saw_any_usage.then(|| total_usage.clone()),
781 final_messages: if context_path.is_none() {
782 messages.clone()
783 } else {
784 vec![]
785 },
786 }))
787 .await;
788 return;
789 }
790 };
791
792 for (inv, outcome) in pairs {
797 let id = inv.id.clone();
798 let outcome = match outcome {
799 Ok(o) => {
800 tracing::info!(
801 target: "harness::tool",
802 step,
803 tool = %inv.name,
804 id = %id,
805 success = o.output.is_ok(),
806 attachments = o.attachments.len(),
807 "tool invocation completed"
808 );
809 o
810 }
811 Err(e) => {
812 let failure = tool_runtime_error_to_failure(
813 &inv,
814 e,
815 tools_snapshot
816 .iter()
817 .find(|s| s.name == inv.name)
818 .map(|s| &s.input_schema),
819 );
820 tracing::warn!(
821 target: "harness::tool",
822 step,
823 tool = %inv.name,
824 id = %id,
825 failure_kind = ?failure.kind,
826 failure_message = %failure.message,
827 "tool invocation failed; returning model-visible ToolResult"
828 );
829 ToolOutcome {
830 output: Err(failure),
831 attachments: vec![],
832 }
833 }
834 };
835 let tool_attachments = outcome.attachments;
836 let output = outcome.output.map_err(|failure| failure.to_string());
837
838 let (tool_content, is_error) = match &output {
844 Ok(value) => (value.to_string(), false),
845 Err(err) => (json!({ "error": err }).to_string(), true),
846 };
847 messages.push(ChatMessage::Tool {
848 tool_call_id: id.clone(),
849 content: tool_content,
850 is_error,
851 attachments: tool_attachments,
852 });
853
854 if tx
855 .send(Ok(HarnessInternalEvent::ToolResult { id, output }))
856 .await
857 .is_err()
858 {
859 return;
860 }
861 }
862 if let Some(ref path) = context_path {
864 crate::context::jsonl::append_context(path, &messages[ctx_written..]).await;
865 ctx_written = messages.len();
866 }
867 }
870 }
871 }
872
873 if let Some(ref path) = context_path {
875 crate::context::jsonl::append_context(path, &messages[ctx_written..]).await;
876 }
877 let final_msgs = if context_path.is_none() {
878 messages
879 } else {
880 vec![]
881 };
882 let _ = tx
883 .send(Ok(HarnessInternalEvent::TurnEnd {
884 stop_reason: "max_turns".into(),
885 usage: saw_any_usage.then(|| total_usage.clone()),
886 final_messages: final_msgs,
887 }))
888 .await;
889}
890
891async fn backoff_sleep(delay_ms: u64, cancel_token: Option<&CancellationToken>) -> bool {
902 let sleep = tokio::time::sleep(Duration::from_millis(delay_ms));
903 tokio::pin!(sleep);
904 let cancelled = async {
905 if let Some(t) = cancel_token {
906 t.cancelled().await
907 } else {
908 std::future::pending().await
909 }
910 };
911 tokio::select! {
912 _ = &mut sleep => true,
913 _ = cancelled => false,
914 }
915}
916
917fn model_error_to_native(err: ModelClientError) -> NativeHarnessError {
918 match err {
919 ModelClientError::RateLimit(s) => NativeHarnessError::ModelRateLimit(s),
920 ModelClientError::Auth(s) => NativeHarnessError::ModelAuth(s),
921 ModelClientError::ContextOverflow(s) => NativeHarnessError::ModelContextOverflow(s),
922 ModelClientError::BadRequest(s) => NativeHarnessError::ModelBadRequest(s),
923 ModelClientError::ServerError(s) => NativeHarnessError::ModelServerError(s),
924 ModelClientError::Network(s) => NativeHarnessError::ModelNetwork(s),
925 ModelClientError::Other(s) => NativeHarnessError::ModelOther(s),
926 }
927}
928
929fn tool_runtime_error_to_failure(
930 inv: &ToolInvocation,
931 err: ToolRuntimeError,
932 schema: Option<&serde_json::Value>,
933) -> ToolFailure {
934 match err {
935 ToolRuntimeError::Timeout(message) => ToolFailure::new(ToolFailureKind::Timeout, message),
936 ToolRuntimeError::InvalidInput { tool, message } => {
937 crate::tools::invalid_input_failure(&tool, message, &inv.input, schema)
938 }
939 ToolRuntimeError::UnknownTool(tool) => ToolFailure::new(
940 ToolFailureKind::InvalidInput,
941 format!("unknown tool {tool}; choose one of the advertised tools"),
942 ),
943 ToolRuntimeError::Runtime(message) => ToolFailure::new(ToolFailureKind::Runtime, message),
944 }
945}
946
947fn panic_payload_to_string(payload: &(dyn std::any::Any + Send)) -> String {
948 if let Some(s) = payload.downcast_ref::<&str>() {
949 (*s).to_string()
950 } else if let Some(s) = payload.downcast_ref::<String>() {
951 s.clone()
952 } else {
953 "non-string panic payload".into()
954 }
955}
956
957struct StepOutcome {
965 next: StepNext,
966 usage: Option<HarnessUsage>,
967 thinking: Option<AssistantThinking>,
968}
969
970enum StepDrain {
975 Complete(StepOutcome),
976 Cancelled,
977}
978
979enum StepFailure {
983 Model {
990 err: ModelClientError,
991 had_progress: bool,
992 },
993 ChannelClosed,
996 Fatal(NativeHarnessError),
999}
1000
1001enum StepNext {
1002 Message {
1003 text: String,
1004 stop_reason: String,
1005 },
1006 ToolCalls {
1012 preface: Option<String>,
1013 invocations: Vec<ToolInvocation>,
1014 },
1015}
1016
1017fn stall_failure(idle_timeout: Duration, had_progress: bool) -> StepFailure {
1028 StepFailure::Model {
1029 err: ModelClientError::Network(format!(
1030 "model stream stalled: no output for {}s (connection open but idle)",
1031 idle_timeout.as_secs()
1032 )),
1033 had_progress,
1034 }
1035}
1036
1037async fn consume_step_stream(
1038 mut stream: futures::stream::BoxStream<'static, Result<ModelChunk, ModelClientError>>,
1039 tx: &mpsc::Sender<Result<HarnessInternalEvent, NativeHarnessError>>,
1040 step: usize,
1041 cancel_token: Option<&CancellationToken>,
1042 idle_timeout: Duration,
1043) -> Result<StepDrain, StepFailure> {
1044 let emit_msg_id = format!("msg_native_{step}");
1045 let emit_thinking_id = format!("thinking_native_{step}");
1046 let mut text_buf = String::new();
1047 let mut text_stream_started = false;
1048 let mut thinking_buf = String::new();
1049 let mut thinking_signature: Option<String> = None;
1050 let mut saw_thinking = false;
1051 let mut tool_states: Vec<ToolBuf> = Vec::new();
1052 let mut stop_reason = "end_turn".to_string();
1053 let mut usage: Option<HarnessUsage> = None;
1054 let mut had_progress = false;
1057
1058 loop {
1059 let idle = tokio::time::sleep(idle_timeout);
1066 tokio::pin!(idle);
1067
1068 let item = if let Some(token) = cancel_token {
1074 tokio::select! {
1075 biased;
1076 _ = token.cancelled() => {
1077 return Ok(StepDrain::Cancelled);
1078 }
1079 _ = &mut idle => return Err(stall_failure(idle_timeout, had_progress)),
1080 next = stream.next() => next,
1081 }
1082 } else {
1083 tokio::select! {
1084 _ = &mut idle => return Err(stall_failure(idle_timeout, had_progress)),
1085 next = stream.next() => next,
1086 }
1087 };
1088 let Some(item) = item else { break };
1089 let chunk = match item {
1090 Ok(c) => c,
1091 Err(e) => {
1092 return Err(StepFailure::Model {
1093 err: e,
1094 had_progress,
1095 })
1096 }
1097 };
1098 match chunk {
1099 ModelChunk::TextDelta { msg_id: _, delta } => {
1100 if delta.is_empty() {
1101 continue;
1102 }
1103 text_buf.push_str(&delta);
1104 let mut flush_delta = delta;
1105 if !text_stream_started {
1106 if text_buf.trim().is_empty() {
1107 continue;
1108 }
1109 text_stream_started = true;
1110 flush_delta = text_buf.clone();
1113 }
1114 had_progress = true;
1117 if tx
1121 .send(Ok(HarnessInternalEvent::AssistantTextChunk {
1122 msg_id: emit_msg_id.clone(),
1123 delta: flush_delta,
1124 }))
1125 .await
1126 .is_err()
1127 {
1128 return Err(StepFailure::ChannelClosed);
1129 }
1130 }
1131 ModelChunk::ThinkingDelta {
1132 thinking_id: _,
1133 delta,
1134 signature,
1135 } => {
1136 if let Some(sig) = signature {
1143 if !sig.is_empty() {
1144 thinking_signature = Some(sig);
1145 }
1146 }
1147 if !delta.is_empty() {
1148 saw_thinking = true;
1149 had_progress = true;
1150 thinking_buf.push_str(&delta);
1151 if tx
1152 .send(Ok(HarnessInternalEvent::AssistantThinkingChunk {
1153 msg_id: emit_thinking_id.clone(),
1154 delta,
1155 }))
1156 .await
1157 .is_err()
1158 {
1159 return Err(StepFailure::ChannelClosed);
1160 }
1161 }
1162 }
1163 ModelChunk::ToolCallStart { id, name } => {
1164 had_progress = true;
1170 tool_states.push(ToolBuf {
1171 id,
1172 name,
1173 args_buf: String::new(),
1174 early_input: None,
1175 });
1176 }
1177 ModelChunk::ToolCallInputDelta { id, delta } => {
1178 if let Some(s) = tool_states.iter_mut().find(|s| s.id == id) {
1179 s.args_buf.push_str(&delta);
1180 }
1181 }
1182 ModelChunk::ToolCallEnd { id, input } => {
1183 if let Some(s) = tool_states.iter_mut().find(|s| s.id == id) {
1184 s.early_input = input;
1185 }
1186 }
1187 ModelChunk::Done {
1188 stop_reason: sr,
1189 usage: u,
1190 } => {
1191 stop_reason = sr;
1192 usage = u;
1193 }
1194 }
1195 }
1196
1197 let thinking = if saw_thinking || thinking_signature.is_some() {
1201 Some(AssistantThinking {
1202 text: thinking_buf,
1203 signature: thinking_signature,
1204 })
1205 } else {
1206 None
1207 };
1208
1209 if !tool_states.is_empty() {
1215 let mut invocations = Vec::with_capacity(tool_states.len());
1216 for state in tool_states {
1217 let parsed_input = match state.early_input {
1218 Some(v) => v,
1219 None => {
1220 let trimmed = state.args_buf.trim();
1221 if trimmed.is_empty() {
1222 Value::Object(serde_json::Map::new())
1226 } else {
1227 match serde_json::from_str(trimmed) {
1228 Ok(v) => v,
1229 Err(e) => {
1230 let res = crate::tool_repair::repair_truncated_json(trimmed);
1236 match serde_json::from_str(&res.repaired) {
1237 Ok(v) if res.changed => {
1238 tracing::warn!(
1239 target: "harness::tool_repair",
1240 tool = %state.name,
1241 id = %state.id,
1242 notes = ?res.notes,
1243 "repaired malformed tool arguments"
1244 );
1245 v
1246 }
1247 _ => {
1248 return Err(StepFailure::Fatal(
1249 NativeHarnessError::ModelOther(format!(
1250 "decode tool arguments for {id}: {e}",
1251 id = state.id
1252 )),
1253 ))
1254 }
1255 }
1256 }
1257 }
1258 }
1259 }
1260 };
1261 let raw_emitted_args = raw_args_for_input(&state.args_buf, &parsed_input);
1262 invocations.push(ToolInvocation {
1263 id: state.id,
1264 name: state.name,
1265 input: parsed_input,
1266 raw_emitted_args,
1267 });
1268 }
1269 return Ok(StepDrain::Complete(StepOutcome {
1270 next: StepNext::ToolCalls {
1271 preface: (!text_buf.is_empty()).then_some(text_buf),
1272 invocations,
1273 },
1274 usage,
1275 thinking,
1276 }));
1277 }
1278
1279 Ok(StepDrain::Complete(StepOutcome {
1280 next: StepNext::Message {
1281 text: text_buf,
1282 stop_reason,
1283 },
1284 usage,
1285 thinking,
1286 }))
1287}
1288
1289struct ToolBuf {
1290 id: String,
1291 name: String,
1292 args_buf: String,
1293 early_input: Option<Value>,
1294}
1295
1296fn raw_args_for_input(raw: &str, input: &Value) -> Option<String> {
1297 let trimmed = raw.trim();
1298 if trimmed.is_empty() {
1299 return None;
1300 }
1301 match serde_json::from_str::<Value>(trimmed) {
1302 Ok(parsed) if parsed == *input => Some(trimmed.to_string()),
1303 _ => None,
1304 }
1305}
1306
1307#[cfg(test)]
1308mod tests {
1309 use super::*;
1310 use crate::compaction::{CompactionContext, CompactionError, CompactionStrategy};
1311 use crate::model::{ModelChunk, ModelClient, ModelClientError, ModelResponse};
1312 use crate::tools::{ToolInvocation, ToolOutcome};
1313 use crate::{HarnessInternalEvent, MockToolRuntime, ScriptedModelClient};
1314 use async_trait::async_trait;
1315 use futures::stream::{BoxStream, StreamExt};
1316
1317 fn search_spec() -> crate::tools::ToolSpec {
1318 crate::tools::web_search::web_search_spec()
1319 }
1320
1321 fn ordinary_spec() -> crate::tools::ToolSpec {
1322 crate::tools::ToolSpec {
1323 name: "read".into(),
1324 description: "read".into(),
1325 input_schema: serde_json::json!({"type": "object"}),
1326 }
1327 }
1328
1329 #[test]
1330 fn web_search_mode_routes_exactly_one_search_surface() {
1331 let both = || vec![ordinary_spec(), search_spec()];
1332
1333 let (tools, hosted) =
1334 resolve_web_search_tools(WebSearchMode::Off, CapabilitySupport::Supported, both())
1335 .unwrap();
1336 assert!(tools.iter().all(|tool| tool.name != "web_search"));
1337 assert!(hosted.is_empty());
1338
1339 let (tools, hosted) =
1340 resolve_web_search_tools(WebSearchMode::Auto, CapabilitySupport::Supported, both())
1341 .unwrap();
1342 assert!(tools.iter().all(|tool| tool.name != "web_search"));
1343 assert_eq!(hosted, vec![HostedTool::WebSearch]);
1344
1345 for support in [CapabilitySupport::Unsupported, CapabilitySupport::Unknown] {
1346 let (tools, hosted) =
1347 resolve_web_search_tools(WebSearchMode::Auto, support, both()).unwrap();
1348 assert!(tools.iter().any(|tool| tool.name == "web_search"));
1349 assert!(hosted.is_empty());
1350 }
1351
1352 let (tools, hosted) =
1353 resolve_web_search_tools(WebSearchMode::Native, CapabilitySupport::Unknown, both())
1354 .unwrap();
1355 assert!(tools.iter().all(|tool| tool.name != "web_search"));
1356 assert_eq!(hosted, vec![HostedTool::WebSearch]);
1357
1358 assert!(resolve_web_search_tools(
1359 WebSearchMode::Native,
1360 CapabilitySupport::Unsupported,
1361 both(),
1362 )
1363 .is_err());
1364
1365 let (tools, hosted) =
1366 resolve_web_search_tools(WebSearchMode::Managed, CapabilitySupport::Supported, both())
1367 .unwrap();
1368 assert!(tools.iter().any(|tool| tool.name == "web_search"));
1369 assert!(hosted.is_empty());
1370
1371 assert!(resolve_web_search_tools(
1372 WebSearchMode::Managed,
1373 CapabilitySupport::Supported,
1374 vec![ordinary_spec()],
1375 )
1376 .is_err());
1377 }
1378
1379 #[test]
1380 fn auto_without_any_search_surface_degrades_to_no_search() {
1381 let (tools, hosted) = resolve_web_search_tools(
1382 WebSearchMode::Auto,
1383 CapabilitySupport::Unknown,
1384 vec![ordinary_spec()],
1385 )
1386 .unwrap();
1387 assert_eq!(tools.len(), 1);
1388 assert!(hosted.is_empty());
1389 }
1390 use std::sync::atomic::{AtomicUsize, Ordering};
1391 use std::sync::{Arc, Mutex};
1392
1393 #[derive(Clone)]
1397 struct QueueModelClient {
1398 queue: Arc<Mutex<Vec<ModelResponse>>>,
1399 }
1400
1401 impl QueueModelClient {
1402 fn new(responses: Vec<ModelResponse>) -> Self {
1403 Self {
1404 queue: Arc::new(Mutex::new(responses)),
1405 }
1406 }
1407 }
1408
1409 #[async_trait]
1410 impl ModelClient for QueueModelClient {
1411 fn hosted_capability(&self, _capability: HostedCapability) -> CapabilitySupport {
1412 CapabilitySupport::Unsupported
1413 }
1414
1415 async fn stream(
1416 &self,
1417 _input: ModelTurnInput,
1418 ) -> Result<BoxStream<'static, Result<ModelChunk, ModelClientError>>, ModelClientError>
1419 {
1420 let mut q = self.queue.lock().unwrap();
1421 if q.is_empty() {
1422 return Err(ModelClientError::Other("queue exhausted".into()));
1423 }
1424 let response = q.remove(0);
1425 let chunks = response_to_chunks(response);
1426 Ok(futures::stream::iter(chunks.into_iter().map(Ok)).boxed())
1427 }
1428 }
1429
1430 fn response_to_chunks(response: ModelResponse) -> Vec<ModelChunk> {
1434 match response {
1435 ModelResponse::Message {
1436 text,
1437 stop_reason,
1438 usage,
1439 } => {
1440 let mut out = Vec::new();
1441 if !text.is_empty() {
1442 out.push(ModelChunk::TextDelta {
1443 msg_id: "queue_msg".into(),
1444 delta: text,
1445 });
1446 }
1447 out.push(ModelChunk::Done { stop_reason, usage });
1448 out
1449 }
1450 ModelResponse::ToolCall {
1451 preface,
1452 invocation,
1453 usage,
1454 } => {
1455 let mut out = Vec::new();
1456 if let Some(p) = preface {
1457 if !p.is_empty() {
1458 out.push(ModelChunk::TextDelta {
1459 msg_id: "queue_msg".into(),
1460 delta: p,
1461 });
1462 }
1463 }
1464 out.push(ModelChunk::ToolCallStart {
1465 id: invocation.id.clone(),
1466 name: invocation.name.clone(),
1467 });
1468 out.push(ModelChunk::ToolCallEnd {
1469 id: invocation.id.clone(),
1470 input: Some(invocation.input.clone()),
1471 });
1472 out.push(ModelChunk::Done {
1473 stop_reason: "end_turn".into(),
1474 usage,
1475 });
1476 out
1477 }
1478 }
1479 }
1480
1481 fn usage(input: u64, output: u64, cache_read: u64) -> HarnessUsage {
1482 HarnessUsage {
1483 input_tokens: input,
1484 output_tokens: output,
1485 cache_read_input_tokens: cache_read,
1486 cache_creation_input_tokens: 0,
1487 compaction_input_tokens: 0,
1488 compaction_output_tokens: 0,
1489 }
1490 }
1491
1492 #[tokio::test]
1493 async fn agent_loop_accumulates_usage_across_steps() {
1494 let model = QueueModelClient::new(vec![
1496 ModelResponse::ToolCall {
1497 preface: None,
1498 invocation: ToolInvocation {
1499 id: "tc_1".into(),
1500 name: "bash".into(),
1501 input: serde_json::json!({"command": "pwd"}),
1502 raw_emitted_args: None,
1503 },
1504 usage: Some(usage(10, 5, 0)),
1505 },
1506 ModelResponse::Message {
1507 text: "done".into(),
1508 stop_reason: "end_turn".into(),
1509 usage: Some(usage(20, 15, 4)),
1510 },
1511 ]);
1512 let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
1513 let mut rx = harness
1514 .run_turn(NativeTurnInput {
1515 prompt_text: "pwd".into(),
1516 system_prompt: None,
1517 attachments: vec![],
1518 cancel_token: None,
1519 prior_messages: vec![],
1520 context_path: None,
1521 })
1522 .await
1523 .unwrap();
1524
1525 let mut final_usage = None;
1527 while let Some(item) = rx.recv().await {
1528 if let HarnessInternalEvent::TurnEnd { usage: u, .. } = item.unwrap() {
1529 final_usage = u;
1530 break;
1531 }
1532 }
1533 let u = final_usage.expect("TurnEnd carried usage");
1534 assert_eq!(u.input_tokens, 30);
1535 assert_eq!(u.output_tokens, 20);
1536 assert_eq!(u.cache_read_input_tokens, 4);
1537 }
1538
1539 #[tokio::test]
1540 async fn agent_loop_errors_on_silent_stop() {
1541 let model = QueueModelClient::new(vec![ModelResponse::Message {
1542 text: " \n".into(),
1543 stop_reason: "end_turn".into(),
1544 usage: None,
1545 }]);
1546 let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
1547 let mut rx = harness
1548 .run_turn(NativeTurnInput {
1549 prompt_text: "say something".into(),
1550 system_prompt: None,
1551 attachments: vec![],
1552 cancel_token: None,
1553 prior_messages: vec![],
1554 context_path: None,
1555 })
1556 .await
1557 .unwrap();
1558
1559 match rx.recv().await.unwrap() {
1560 Err(NativeHarnessError::ModelOther(msg)) => {
1561 assert!(msg.contains("silent_stop"));
1562 assert!(msg.contains("stop_reason=end_turn"));
1563 }
1564 other => panic!("expected silent_stop model error, got {other:?}"),
1565 }
1566 }
1567
1568 #[tokio::test]
1569 async fn agent_loop_errors_on_empty_max_tokens_stop() {
1570 let model = QueueModelClient::new(vec![ModelResponse::Message {
1571 text: "".into(),
1572 stop_reason: "max_tokens".into(),
1573 usage: None,
1574 }]);
1575 let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
1576 let mut rx = harness
1577 .run_turn(NativeTurnInput {
1578 prompt_text: "think".into(),
1579 system_prompt: None,
1580 attachments: vec![],
1581 cancel_token: None,
1582 prior_messages: vec![],
1583 context_path: None,
1584 })
1585 .await
1586 .unwrap();
1587
1588 match rx.recv().await.unwrap() {
1589 Err(NativeHarnessError::ModelOther(msg)) => {
1590 assert!(msg.contains("silent_stop"));
1591 assert!(msg.contains("stop_reason=max_tokens"));
1592 }
1593 other => panic!("expected silent_stop model error, got {other:?}"),
1594 }
1595 }
1596
1597 #[tokio::test]
1598 async fn agent_loop_turn_end_usage_is_none_when_no_step_reported() {
1599 let model = QueueModelClient::new(vec![ModelResponse::Message {
1601 text: "ok".into(),
1602 stop_reason: "end_turn".into(),
1603 usage: None,
1604 }]);
1605 let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
1606 let mut rx = harness
1607 .run_turn(NativeTurnInput {
1608 prompt_text: "noop".into(),
1609 system_prompt: None,
1610 attachments: vec![],
1611 cancel_token: None,
1612 prior_messages: vec![],
1613 context_path: None,
1614 })
1615 .await
1616 .unwrap();
1617 let mut saw_usage = None;
1618 while let Some(item) = rx.recv().await {
1619 if let HarnessInternalEvent::TurnEnd { usage, .. } = item.unwrap() {
1620 saw_usage = Some(usage);
1621 break;
1622 }
1623 }
1624 assert_eq!(saw_usage.unwrap(), None);
1625 }
1626
1627 #[derive(Clone)]
1632 struct StreamingFakeClient {
1633 chunks_per_call: Arc<Mutex<Vec<Vec<ModelChunk>>>>,
1634 }
1635
1636 impl StreamingFakeClient {
1637 fn new(per_call: Vec<Vec<ModelChunk>>) -> Self {
1638 Self {
1639 chunks_per_call: Arc::new(Mutex::new(per_call)),
1640 }
1641 }
1642 }
1643
1644 #[async_trait]
1645 impl ModelClient for StreamingFakeClient {
1646 fn hosted_capability(&self, _capability: HostedCapability) -> CapabilitySupport {
1647 CapabilitySupport::Unsupported
1648 }
1649
1650 async fn stream(
1651 &self,
1652 _input: ModelTurnInput,
1653 ) -> Result<BoxStream<'static, Result<ModelChunk, ModelClientError>>, ModelClientError>
1654 {
1655 let mut bucket = self.chunks_per_call.lock().unwrap();
1656 if bucket.is_empty() {
1657 return Err(ModelClientError::Other("queue exhausted".into()));
1658 }
1659 let chunks = bucket.remove(0);
1660 Ok(futures::stream::iter(chunks.into_iter().map(Ok)).boxed())
1661 }
1662 }
1663
1664 #[tokio::test]
1665 async fn agent_loop_forwards_token_chunks_to_harness_output() {
1666 let model = StreamingFakeClient::new(vec![vec![
1667 ModelChunk::TextDelta {
1668 msg_id: "remote_msg".into(),
1669 delta: "Hel".into(),
1670 },
1671 ModelChunk::TextDelta {
1672 msg_id: "remote_msg".into(),
1673 delta: "lo ".into(),
1674 },
1675 ModelChunk::TextDelta {
1676 msg_id: "remote_msg".into(),
1677 delta: "world".into(),
1678 },
1679 ModelChunk::Done {
1680 stop_reason: "end_turn".into(),
1681 usage: None,
1682 },
1683 ]]);
1684 let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
1685 let mut rx = harness
1686 .run_turn(NativeTurnInput {
1687 prompt_text: "hi".into(),
1688 system_prompt: None,
1689 attachments: vec![],
1690 cancel_token: None,
1691 prior_messages: vec![],
1692 context_path: None,
1693 })
1694 .await
1695 .unwrap();
1696
1697 let mut deltas: Vec<String> = Vec::new();
1698 let mut saw_end = false;
1699 while let Some(item) = rx.recv().await {
1700 match item.unwrap() {
1701 HarnessInternalEvent::AssistantTextChunk { msg_id, delta } => {
1702 assert_eq!(msg_id, "msg_native_0");
1706 deltas.push(delta);
1707 }
1708 HarnessInternalEvent::TurnEnd { stop_reason, .. } => {
1709 assert_eq!(stop_reason, "end_turn");
1710 saw_end = true;
1711 break;
1712 }
1713 other => panic!("unexpected event: {other:?}"),
1714 }
1715 }
1716 assert_eq!(deltas, vec!["Hel", "lo ", "world"]);
1717 assert!(saw_end);
1718 }
1719
1720 #[tokio::test]
1721 async fn agent_loop_streaming_tool_call_then_summary() {
1722 let model = StreamingFakeClient::new(vec![
1729 vec![
1730 ModelChunk::TextDelta {
1731 msg_id: "r1".into(),
1732 delta: "running ".into(),
1733 },
1734 ModelChunk::ToolCallStart {
1735 id: "tc_1".into(),
1736 name: "bash".into(),
1737 },
1738 ModelChunk::ToolCallInputDelta {
1739 id: "tc_1".into(),
1740 delta: "{\"command\":".into(),
1741 },
1742 ModelChunk::ToolCallInputDelta {
1743 id: "tc_1".into(),
1744 delta: "\"pwd\"}".into(),
1745 },
1746 ModelChunk::ToolCallEnd {
1747 id: "tc_1".into(),
1748 input: None,
1749 },
1750 ModelChunk::Done {
1751 stop_reason: "end_turn".into(),
1752 usage: None,
1753 },
1754 ],
1755 vec![
1756 ModelChunk::TextDelta {
1757 msg_id: "r2".into(),
1758 delta: "done".into(),
1759 },
1760 ModelChunk::Done {
1761 stop_reason: "end_turn".into(),
1762 usage: None,
1763 },
1764 ],
1765 ]);
1766 let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
1767 let mut rx = harness
1768 .run_turn(NativeTurnInput {
1769 prompt_text: "pwd".into(),
1770 system_prompt: None,
1771 attachments: vec![],
1772 cancel_token: None,
1773 prior_messages: vec![],
1774 context_path: None,
1775 })
1776 .await
1777 .unwrap();
1778
1779 let ev = rx.recv().await.unwrap().unwrap();
1786 assert!(matches!(
1787 ev,
1788 HarnessInternalEvent::AssistantTextChunk { ref delta, .. } if delta == "running "
1789 ));
1790 let ev = rx.recv().await.unwrap().unwrap();
1791 let HarnessInternalEvent::ToolCall { name, input, .. } = ev else {
1792 panic!("expected ToolCall");
1793 };
1794 assert_eq!(name, "bash");
1795 assert_eq!(input["command"], "pwd");
1796 let ev = rx.recv().await.unwrap().unwrap();
1797 assert!(matches!(ev, HarnessInternalEvent::ToolResult { .. }));
1798 let ev = rx.recv().await.unwrap().unwrap();
1799 assert!(matches!(
1800 ev,
1801 HarnessInternalEvent::AssistantTextChunk { ref delta, .. } if delta == "done"
1802 ));
1803 let ev = rx.recv().await.unwrap().unwrap();
1804 assert!(matches!(ev, HarnessInternalEvent::TurnEnd { .. }));
1805 }
1806
1807 #[tokio::test]
1808 async fn agent_loop_repairs_truncated_tool_arguments() {
1809 let model = StreamingFakeClient::new(vec![
1813 vec![
1814 ModelChunk::ToolCallStart {
1815 id: "tc_trunc".into(),
1816 name: "bash".into(),
1817 },
1818 ModelChunk::ToolCallInputDelta {
1819 id: "tc_trunc".into(),
1820 delta: r#"{"command":"pwd""#.into(), },
1822 ModelChunk::ToolCallEnd {
1823 id: "tc_trunc".into(),
1824 input: None,
1825 },
1826 ModelChunk::Done {
1827 stop_reason: "tool_use".into(),
1828 usage: None,
1829 },
1830 ],
1831 vec![
1832 ModelChunk::TextDelta {
1833 msg_id: "r2".into(),
1834 delta: "done".into(),
1835 },
1836 ModelChunk::Done {
1837 stop_reason: "end_turn".into(),
1838 usage: None,
1839 },
1840 ],
1841 ]);
1842 let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
1843 let mut rx = harness
1844 .run_turn(NativeTurnInput {
1845 prompt_text: "pwd".into(),
1846 system_prompt: None,
1847 attachments: vec![],
1848 cancel_token: None,
1849 prior_messages: vec![],
1850 context_path: None,
1851 })
1852 .await
1853 .unwrap();
1854
1855 let mut saw_tool_call = false;
1856 let mut saw_turn_end = false;
1857 while let Some(item) = rx.recv().await {
1858 match item.expect("turn must not fail on truncated args") {
1859 HarnessInternalEvent::ToolCall { name, input, .. } => {
1860 assert_eq!(name, "bash");
1861 assert_eq!(input["command"], "pwd", "repaired args reach the wire");
1862 saw_tool_call = true;
1863 }
1864 HarnessInternalEvent::TurnEnd { .. } => {
1865 saw_turn_end = true;
1866 break;
1867 }
1868 _ => {}
1869 }
1870 }
1871 assert!(saw_tool_call, "expected ToolCall with repaired input");
1872 assert!(saw_turn_end);
1873 }
1874
1875 #[derive(Clone)]
1878 struct ProbeToolRuntime {
1879 seen_input: Arc<Mutex<Option<Value>>>,
1880 }
1881
1882 #[async_trait]
1883 impl ToolRuntime for ProbeToolRuntime {
1884 fn specs(&self) -> Vec<crate::tools::ToolSpec> {
1885 vec![crate::tools::ToolSpec {
1886 name: "probe".into(),
1887 description: "records its input".into(),
1888 input_schema: serde_json::json!({
1889 "type": "object",
1890 "properties": {
1891 "pattern": {"type": "string"},
1892 "literal": {"type": "boolean"},
1893 "limit": {"type": "integer"}
1894 },
1895 "required": ["pattern"]
1896 }),
1897 }]
1898 }
1899
1900 async fn invoke(
1901 &self,
1902 invocation: ToolInvocation,
1903 ) -> Result<ToolOutcome, ToolRuntimeError> {
1904 *self.seen_input.lock().unwrap() = Some(invocation.input);
1905 Ok(ToolOutcome {
1906 output: Ok(r#"{"ok":true}"#.into()),
1907 attachments: vec![],
1908 })
1909 }
1910 }
1911
1912 #[tokio::test]
1913 async fn agent_loop_applies_schema_repair_before_dispatch() {
1914 let model = StreamingFakeClient::new(vec![
1918 vec![
1919 ModelChunk::ToolCallStart {
1920 id: "tc_shape".into(),
1921 name: "probe".into(),
1922 },
1923 ModelChunk::ToolCallEnd {
1924 id: "tc_shape".into(),
1925 input: Some(json!({"pattern": "x", "literal": "true", "limit": "30"})),
1926 },
1927 ModelChunk::Done {
1928 stop_reason: "tool_use".into(),
1929 usage: None,
1930 },
1931 ],
1932 vec![
1933 ModelChunk::TextDelta {
1934 msg_id: "r2".into(),
1935 delta: "done".into(),
1936 },
1937 ModelChunk::Done {
1938 stop_reason: "end_turn".into(),
1939 usage: None,
1940 },
1941 ],
1942 ]);
1943 let seen_input = Arc::new(Mutex::new(None));
1944 let tools = ProbeToolRuntime {
1945 seen_input: seen_input.clone(),
1946 };
1947 let harness = AgentLoopHarness::new(model, tools);
1948 let mut rx = harness
1949 .run_turn(NativeTurnInput {
1950 prompt_text: "go".into(),
1951 system_prompt: None,
1952 attachments: vec![],
1953 cancel_token: None,
1954 prior_messages: vec![],
1955 context_path: None,
1956 })
1957 .await
1958 .unwrap();
1959
1960 let mut wire_input: Option<Value> = None;
1961 let mut history: Option<Vec<ChatMessage>> = None;
1962 while let Some(item) = rx.recv().await {
1963 match item.unwrap() {
1964 HarnessInternalEvent::ToolCall { input, .. } => wire_input = Some(input),
1965 HarnessInternalEvent::TurnEnd { final_messages, .. } => {
1966 history = Some(final_messages);
1967 break;
1968 }
1969 _ => {}
1970 }
1971 }
1972 let repaired = json!({"pattern": "x", "literal": true, "limit": 30});
1973 assert_eq!(seen_input.lock().unwrap().clone().unwrap(), repaired);
1975 assert_eq!(wire_input.unwrap(), repaired);
1976 let history = history.unwrap();
1977 let assistant_tool_calls = history
1978 .iter()
1979 .find_map(|m| match m {
1980 ChatMessage::Assistant { tool_calls, .. } if !tool_calls.is_empty() => {
1981 Some(tool_calls.clone())
1982 }
1983 _ => None,
1984 })
1985 .expect("assistant message with tool_calls in history");
1986 assert_eq!(assistant_tool_calls[0].input, repaired);
1987 }
1988
1989 #[derive(Clone)]
1990 struct TimeoutToolRuntime;
1991
1992 #[async_trait]
1993 impl ToolRuntime for TimeoutToolRuntime {
1994 fn specs(&self) -> Vec<crate::tools::ToolSpec> {
1995 vec![crate::tools::ToolSpec {
1996 name: "slow".into(),
1997 description: "always times out".into(),
1998 input_schema: serde_json::json!({"type": "object"}),
1999 }]
2000 }
2001
2002 async fn invoke(
2003 &self,
2004 _invocation: ToolInvocation,
2005 ) -> Result<ToolOutcome, ToolRuntimeError> {
2006 Err(ToolRuntimeError::Timeout("tool timed out after 1s".into()))
2007 }
2008 }
2009
2010 #[tokio::test]
2011 async fn agent_loop_tool_timeout_is_model_observable_result() {
2012 let model = StreamingFakeClient::new(vec![
2013 vec![
2014 ModelChunk::ToolCallStart {
2015 id: "tc_timeout".into(),
2016 name: "slow".into(),
2017 },
2018 ModelChunk::ToolCallEnd {
2019 id: "tc_timeout".into(),
2020 input: Some(json!({})),
2021 },
2022 ModelChunk::Done {
2023 stop_reason: "tool_use".into(),
2024 usage: None,
2025 },
2026 ],
2027 vec![
2028 ModelChunk::TextDelta {
2029 msg_id: "r2".into(),
2030 delta: "recovered".into(),
2031 },
2032 ModelChunk::Done {
2033 stop_reason: "end_turn".into(),
2034 usage: None,
2035 },
2036 ],
2037 ]);
2038 let harness = AgentLoopHarness::new(model, TimeoutToolRuntime);
2039 let mut rx = harness
2040 .run_turn(NativeTurnInput {
2041 prompt_text: "run slow".into(),
2042 system_prompt: None,
2043 attachments: vec![],
2044 cancel_token: None,
2045 prior_messages: vec![],
2046 context_path: None,
2047 })
2048 .await
2049 .unwrap();
2050
2051 assert!(matches!(
2052 rx.recv().await.unwrap().unwrap(),
2053 HarnessInternalEvent::ToolCall { .. }
2054 ));
2055 match rx.recv().await.unwrap().unwrap() {
2056 HarnessInternalEvent::ToolResult { output, .. } => {
2057 let err = output.unwrap_err();
2058 assert!(err.contains("Timeout"));
2059 assert!(err.contains("tool timed out"));
2060 }
2061 other => panic!("expected timeout ToolResult, got {other:?}"),
2062 }
2063 assert!(matches!(
2064 rx.recv().await.unwrap().unwrap(),
2065 HarnessInternalEvent::AssistantTextChunk { ref delta, .. } if delta == "recovered"
2066 ));
2067 assert!(matches!(
2068 rx.recv().await.unwrap().unwrap(),
2069 HarnessInternalEvent::TurnEnd { ref stop_reason, .. } if stop_reason == "end_turn"
2070 ));
2071 }
2072
2073 #[derive(Clone)]
2074 struct RuntimeErrorToolRuntime;
2075
2076 #[async_trait]
2077 impl ToolRuntime for RuntimeErrorToolRuntime {
2078 fn specs(&self) -> Vec<crate::tools::ToolSpec> {
2079 vec![crate::tools::ToolSpec {
2080 name: "flaky".into(),
2081 description: "always returns a runtime failure".into(),
2082 input_schema: serde_json::json!({"type": "object"}),
2083 }]
2084 }
2085
2086 async fn invoke(
2087 &self,
2088 _invocation: ToolInvocation,
2089 ) -> Result<ToolOutcome, ToolRuntimeError> {
2090 Err(ToolRuntimeError::Runtime(
2091 "sandbox exec stream closed".into(),
2092 ))
2093 }
2094 }
2095
2096 #[tokio::test]
2097 async fn agent_loop_tool_runtime_error_is_model_observable_result() {
2098 let model = StreamingFakeClient::new(vec![
2099 vec![
2100 ModelChunk::ToolCallStart {
2101 id: "tc_runtime".into(),
2102 name: "flaky".into(),
2103 },
2104 ModelChunk::ToolCallEnd {
2105 id: "tc_runtime".into(),
2106 input: Some(json!({})),
2107 },
2108 ModelChunk::Done {
2109 stop_reason: "tool_use".into(),
2110 usage: None,
2111 },
2112 ],
2113 vec![
2114 ModelChunk::TextDelta {
2115 msg_id: "r2".into(),
2116 delta: "recovered".into(),
2117 },
2118 ModelChunk::Done {
2119 stop_reason: "end_turn".into(),
2120 usage: None,
2121 },
2122 ],
2123 ]);
2124 let harness = AgentLoopHarness::new(model, RuntimeErrorToolRuntime);
2125 let mut rx = harness
2126 .run_turn(NativeTurnInput {
2127 prompt_text: "run flaky".into(),
2128 system_prompt: None,
2129 attachments: vec![],
2130 cancel_token: None,
2131 prior_messages: vec![],
2132 context_path: None,
2133 })
2134 .await
2135 .unwrap();
2136
2137 assert!(matches!(
2138 rx.recv().await.unwrap().unwrap(),
2139 HarnessInternalEvent::ToolCall { .. }
2140 ));
2141 match rx.recv().await.unwrap().unwrap() {
2142 HarnessInternalEvent::ToolResult { id, output } => {
2143 assert_eq!(id, "tc_runtime");
2144 let err = output.unwrap_err();
2145 assert!(err.contains("Runtime"));
2146 assert!(err.contains("sandbox exec stream closed"));
2147 }
2148 other => panic!("expected runtime-error ToolResult, got {other:?}"),
2149 }
2150 assert!(matches!(
2151 rx.recv().await.unwrap().unwrap(),
2152 HarnessInternalEvent::AssistantTextChunk { ref delta, .. } if delta == "recovered"
2153 ));
2154 assert!(matches!(
2155 rx.recv().await.unwrap().unwrap(),
2156 HarnessInternalEvent::TurnEnd { ref stop_reason, .. } if stop_reason == "end_turn"
2157 ));
2158 }
2159
2160 #[tokio::test]
2161 async fn agent_loop_invalid_tool_input_is_model_observable_and_bounded() {
2162 let huge_content = "x".repeat(20_000);
2163 let model = StreamingFakeClient::new(vec![
2164 vec![
2165 ModelChunk::ToolCallStart {
2166 id: "tc_bad_write".into(),
2167 name: "write".into(),
2168 },
2169 ModelChunk::ToolCallEnd {
2170 id: "tc_bad_write".into(),
2171 input: Some(json!({"content": huge_content})),
2172 },
2173 ModelChunk::Done {
2174 stop_reason: "tool_use".into(),
2175 usage: None,
2176 },
2177 ],
2178 vec![
2179 ModelChunk::TextDelta {
2180 msg_id: "r2".into(),
2181 delta: "recovered".into(),
2182 },
2183 ModelChunk::Done {
2184 stop_reason: "end_turn".into(),
2185 usage: None,
2186 },
2187 ],
2188 ]);
2189 let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
2190 let mut rx = harness
2191 .run_turn(NativeTurnInput {
2192 prompt_text: "write file".into(),
2193 system_prompt: None,
2194 attachments: vec![],
2195 cancel_token: None,
2196 prior_messages: vec![],
2197 context_path: None,
2198 })
2199 .await
2200 .unwrap();
2201
2202 assert!(matches!(
2203 rx.recv().await.unwrap().unwrap(),
2204 HarnessInternalEvent::ToolCall { .. }
2205 ));
2206 match rx.recv().await.unwrap().unwrap() {
2207 HarnessInternalEvent::ToolResult { output, .. } => {
2208 let err = output.unwrap_err();
2209 assert!(err.contains("The write tool was called with invalid arguments"));
2212 assert!(err.contains("missing required field `path`"), "{err}");
2213 assert!(err.contains("Received fields: content"));
2214 assert!(err.contains("string(20000 chars"));
2215 assert!(
2216 err.contains("Expected shape"),
2217 "teaching example missing: {err}"
2218 );
2219 assert!(
2220 !err.contains(&"x".repeat(2000)),
2221 "error should not echo full content"
2222 );
2223 }
2224 other => panic!("expected invalid-input ToolResult, got {other:?}"),
2225 }
2226 assert!(matches!(
2227 rx.recv().await.unwrap().unwrap(),
2228 HarnessInternalEvent::AssistantTextChunk { ref delta, .. } if delta == "recovered"
2229 ));
2230 }
2231
2232 struct CountingCompactionStrategy {
2236 calls: Arc<AtomicUsize>,
2237 }
2238
2239 #[async_trait]
2240 impl CompactionStrategy for CountingCompactionStrategy {
2241 fn should_compact(&self, _messages: &[ChatMessage], _context_window_tokens: u64) -> bool {
2242 true
2243 }
2244
2245 async fn compact(
2246 &self,
2247 _messages: Vec<ChatMessage>,
2248 _ctx: &CompactionContext,
2249 ) -> Result<crate::compaction::CompactionOutcome, CompactionError> {
2250 self.calls.fetch_add(1, Ordering::SeqCst);
2251 Ok(crate::compaction::CompactionOutcome {
2254 messages: vec![ChatMessage::User {
2255 content: "<conversation-summary>FOLDED</conversation-summary>".into(),
2256 attachments: vec![],
2257 }],
2258 usage: None,
2259 })
2260 }
2261 }
2262
2263 struct UsageReportingCompactionStrategy {
2267 invoked: Arc<AtomicUsize>,
2268 per_call_usage: HarnessUsage,
2269 }
2270
2271 #[async_trait]
2272 impl CompactionStrategy for UsageReportingCompactionStrategy {
2273 fn should_compact(&self, _: &[ChatMessage], _: u64) -> bool {
2274 self.invoked.load(Ordering::SeqCst) == 0
2278 }
2279 async fn compact(
2280 &self,
2281 messages: Vec<ChatMessage>,
2282 _ctx: &CompactionContext,
2283 ) -> Result<crate::compaction::CompactionOutcome, CompactionError> {
2284 self.invoked.fetch_add(1, Ordering::SeqCst);
2285 Ok(crate::compaction::CompactionOutcome {
2286 messages,
2287 usage: Some(self.per_call_usage.clone()),
2288 })
2289 }
2290 }
2291
2292 #[tokio::test]
2293 async fn agent_loop_attributes_compaction_usage_to_subbucket_and_total() {
2294 let model = StreamingFakeClient::new(vec![vec![
2298 ModelChunk::TextDelta {
2299 msg_id: "m".into(),
2300 delta: "done".into(),
2301 },
2302 ModelChunk::Done {
2303 stop_reason: "end_turn".into(),
2304 usage: Some(usage(100, 30, 0)),
2305 },
2306 ]]);
2307 let invoked = Arc::new(AtomicUsize::new(0));
2308 let strategy = UsageReportingCompactionStrategy {
2309 invoked: invoked.clone(),
2310 per_call_usage: usage(50, 20, 0),
2311 };
2312 let policy = CompactionPolicy::new(
2313 Arc::new(strategy),
2314 Arc::new(ScriptedModelClient),
2315 1, );
2317 let harness = AgentLoopHarness::new(model, MockToolRuntime::new()).with_compaction(policy);
2318 let mut rx = harness
2319 .run_turn(NativeTurnInput {
2320 prompt_text: "hi".into(),
2321 system_prompt: None,
2322 attachments: vec![],
2323 cancel_token: None,
2324 prior_messages: vec![],
2325 context_path: None,
2326 })
2327 .await
2328 .unwrap();
2329 let mut final_usage = None;
2330 while let Some(item) = rx.recv().await {
2331 if let HarnessInternalEvent::TurnEnd { usage, .. } = item.unwrap() {
2332 final_usage = usage;
2333 break;
2334 }
2335 }
2336 assert_eq!(invoked.load(Ordering::SeqCst), 1);
2337 let u = final_usage.expect("TurnEnd carried usage");
2338 assert_eq!(u.input_tokens, 150);
2340 assert_eq!(u.output_tokens, 50);
2341 assert_eq!(u.compaction_input_tokens, 50);
2343 assert_eq!(u.compaction_output_tokens, 20);
2344 }
2345
2346 #[derive(Clone)]
2350 struct RecordingFakeClient {
2351 last_messages: Arc<Mutex<Option<Vec<ChatMessage>>>>,
2352 chunks: Vec<ModelChunk>,
2353 }
2354
2355 #[async_trait]
2356 impl ModelClient for RecordingFakeClient {
2357 fn hosted_capability(&self, _capability: HostedCapability) -> CapabilitySupport {
2358 CapabilitySupport::Unsupported
2359 }
2360
2361 async fn stream(
2362 &self,
2363 input: ModelTurnInput,
2364 ) -> Result<BoxStream<'static, Result<ModelChunk, ModelClientError>>, ModelClientError>
2365 {
2366 *self.last_messages.lock().unwrap() = Some(input.messages);
2367 Ok(futures::stream::iter(self.chunks.clone().into_iter().map(Ok)).boxed())
2368 }
2369 }
2370
2371 #[tokio::test]
2372 async fn agent_loop_invokes_compaction_between_steps() {
2373 let calls = Arc::new(AtomicUsize::new(0));
2374 let last_messages = Arc::new(Mutex::new(None::<Vec<ChatMessage>>));
2375 let model = RecordingFakeClient {
2376 last_messages: last_messages.clone(),
2377 chunks: vec![
2378 ModelChunk::TextDelta {
2379 msg_id: "m".into(),
2380 delta: "done".into(),
2381 },
2382 ModelChunk::Done {
2383 stop_reason: "end_turn".into(),
2384 usage: None,
2385 },
2386 ],
2387 };
2388 let summary_client: Arc<dyn ModelClient> = Arc::new(ScriptedModelClient);
2392 let policy = CompactionPolicy::new(
2393 Arc::new(CountingCompactionStrategy {
2394 calls: calls.clone(),
2395 }),
2396 summary_client,
2397 1, );
2399
2400 let harness = AgentLoopHarness::new(model, MockToolRuntime::new()).with_compaction(policy);
2401 let mut rx = harness
2402 .run_turn(NativeTurnInput {
2403 prompt_text: "hello".into(),
2404 system_prompt: None,
2405 attachments: vec![],
2406 cancel_token: None,
2407 prior_messages: vec![],
2408 context_path: None,
2409 })
2410 .await
2411 .unwrap();
2412 let mut compaction_event: Option<(usize, usize)> = None;
2413 while let Some(item) = rx.recv().await {
2414 match item.unwrap() {
2415 HarnessInternalEvent::CompactionApplied {
2416 original_message_count,
2417 compacted_message_count,
2418 ..
2419 } => {
2420 compaction_event = Some((original_message_count, compacted_message_count));
2421 }
2422 HarnessInternalEvent::TurnEnd { .. } => break,
2423 _ => {}
2424 }
2425 }
2426 assert_eq!(calls.load(Ordering::SeqCst), 1);
2428 let (orig, comp) = compaction_event.expect("CompactionApplied event emitted");
2430 assert_eq!(orig, 1, "started with 1 message ([User \"hello\"])");
2431 assert_eq!(comp, 1, "spy strategy folded to single User message");
2432 let observed = last_messages.lock().unwrap().clone().expect("model called");
2435 assert_eq!(observed.len(), 1);
2436 match &observed[0] {
2437 ChatMessage::User { content, .. } => {
2438 assert!(content.contains("FOLDED"), "got {content:?}");
2439 }
2440 other => panic!("expected User, got {other:?}"),
2441 }
2442 }
2443
2444 #[test]
2445 fn compaction_policy_summarizing_uses_default_strategy() {
2446 let policy = CompactionPolicy::summarizing(Arc::new(ScriptedModelClient), 100_000);
2447
2448 assert_eq!(policy.context_window_tokens, 100_000);
2449 assert!(!policy.strategy.should_compact(&[], 100_000));
2450 }
2451
2452 #[derive(Clone)]
2456 struct HangingModelClient {
2457 started: Arc<tokio::sync::Notify>,
2458 }
2459
2460 #[async_trait]
2461 impl ModelClient for HangingModelClient {
2462 fn hosted_capability(&self, _capability: HostedCapability) -> CapabilitySupport {
2463 CapabilitySupport::Unsupported
2464 }
2465
2466 async fn stream(
2467 &self,
2468 _input: ModelTurnInput,
2469 ) -> Result<BoxStream<'static, Result<ModelChunk, ModelClientError>>, ModelClientError>
2470 {
2471 let (tx, rx) = mpsc::channel::<Result<ModelChunk, ModelClientError>>(1);
2476 let started = self.started.clone();
2477 tokio::spawn(async move {
2478 started.notify_one();
2483 let _retain = tx; let () = std::future::pending().await;
2485 });
2486 Ok(tokio_stream::wrappers::ReceiverStream::new(rx).boxed())
2487 }
2488 }
2489
2490 #[tokio::test]
2491 async fn agent_loop_cancellation_interrupts_in_flight_stream() {
2492 let started = Arc::new(tokio::sync::Notify::new());
2497 let model = HangingModelClient {
2498 started: started.clone(),
2499 };
2500 let cancel = CancellationToken::new();
2501 let cancel_for_outside = cancel.clone();
2502
2503 let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
2504 let mut rx = harness
2505 .run_turn(NativeTurnInput {
2506 prompt_text: "hi".into(),
2507 system_prompt: None,
2508 attachments: vec![],
2509 cancel_token: Some(cancel),
2510 prior_messages: vec![],
2511 context_path: None,
2512 })
2513 .await
2514 .unwrap();
2515
2516 started.notified().await;
2521 cancel_for_outside.cancel();
2522
2523 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
2525 let mut saw_interrupt = false;
2526 while tokio::time::Instant::now() < deadline {
2527 tokio::select! {
2528 item = rx.recv() => {
2529 match item {
2530 Some(Ok(HarnessInternalEvent::TurnEnd { stop_reason, .. })) => {
2531 assert_eq!(stop_reason, "interrupt");
2532 saw_interrupt = true;
2533 break;
2534 }
2535 Some(_) => continue,
2536 None => break,
2537 }
2538 }
2539 _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {}
2540 }
2541 }
2542 assert!(saw_interrupt, "expected TurnEnd{{interrupt}} after cancel");
2543 }
2544
2545 enum StallBehavior {
2552 Complete(Vec<ModelChunk>),
2554 EmitThenHang(Vec<ModelChunk>),
2556 }
2557
2558 #[derive(Clone)]
2559 struct StallingModelClient {
2560 behaviors: Arc<Mutex<Vec<StallBehavior>>>,
2561 calls: Arc<AtomicUsize>,
2562 }
2563
2564 impl StallingModelClient {
2565 fn new(behaviors: Vec<StallBehavior>) -> Self {
2566 Self {
2567 behaviors: Arc::new(Mutex::new(behaviors)),
2568 calls: Arc::new(AtomicUsize::new(0)),
2569 }
2570 }
2571 }
2572
2573 #[async_trait]
2574 impl ModelClient for StallingModelClient {
2575 fn hosted_capability(&self, _capability: HostedCapability) -> CapabilitySupport {
2576 CapabilitySupport::Unsupported
2577 }
2578
2579 async fn stream(
2580 &self,
2581 _input: ModelTurnInput,
2582 ) -> Result<BoxStream<'static, Result<ModelChunk, ModelClientError>>, ModelClientError>
2583 {
2584 self.calls.fetch_add(1, Ordering::SeqCst);
2585 let behavior = {
2588 let mut b = self.behaviors.lock().unwrap();
2589 if b.is_empty() {
2590 StallBehavior::EmitThenHang(vec![])
2591 } else {
2592 b.remove(0)
2593 }
2594 };
2595 let (tx, rx) = mpsc::channel::<Result<ModelChunk, ModelClientError>>(8);
2596 tokio::spawn(async move {
2597 match behavior {
2598 StallBehavior::Complete(chunks) => {
2599 for c in chunks {
2600 if tx.send(Ok(c)).await.is_err() {
2601 return;
2602 }
2603 }
2604 }
2606 StallBehavior::EmitThenHang(chunks) => {
2607 for c in chunks {
2608 if tx.send(Ok(c)).await.is_err() {
2609 return;
2610 }
2611 }
2612 let _retain = tx; let () = std::future::pending().await;
2614 }
2615 }
2616 });
2617 Ok(tokio_stream::wrappers::ReceiverStream::new(rx).boxed())
2618 }
2619 }
2620
2621 #[tokio::test(start_paused = true)]
2622 async fn agent_loop_reconnects_after_stall_before_any_output() {
2623 let model = StallingModelClient::new(vec![
2628 StallBehavior::EmitThenHang(vec![]),
2629 StallBehavior::Complete(vec![
2630 ModelChunk::TextDelta {
2631 msg_id: "m".into(),
2632 delta: "ok".into(),
2633 },
2634 ModelChunk::Done {
2635 stop_reason: "end_turn".into(),
2636 usage: None,
2637 },
2638 ]),
2639 ]);
2640 let calls = model.calls.clone();
2641 let harness = AgentLoopHarness::new(model, MockToolRuntime::new())
2642 .with_stream_resilience(Duration::from_millis(50), 3);
2643 let mut rx = harness
2644 .run_turn(NativeTurnInput {
2645 prompt_text: "hi".into(),
2646 system_prompt: None,
2647 attachments: vec![],
2648 cancel_token: None,
2649 prior_messages: vec![],
2650 context_path: None,
2651 })
2652 .await
2653 .unwrap();
2654
2655 let mut text = String::new();
2656 let mut stop = None;
2657 while let Some(item) = rx.recv().await {
2658 match item.expect("no error expected") {
2659 HarnessInternalEvent::AssistantTextChunk { delta, .. } => text.push_str(&delta),
2660 HarnessInternalEvent::TurnEnd { stop_reason, .. } => {
2661 stop = Some(stop_reason);
2662 break;
2663 }
2664 _ => {}
2665 }
2666 }
2667 assert_eq!(stop.as_deref(), Some("end_turn"));
2668 assert_eq!(text, "ok", "text delivered exactly once, no duplication");
2669 assert_eq!(
2670 calls.load(Ordering::SeqCst),
2671 2,
2672 "stream established twice (one reconnect)"
2673 );
2674 }
2675
2676 #[tokio::test(start_paused = true)]
2677 async fn agent_loop_surfaces_error_when_reconnect_budget_exhausted() {
2678 let model = StallingModelClient::new(vec![]); let calls = model.calls.clone();
2682 let harness = AgentLoopHarness::new(model, MockToolRuntime::new())
2683 .with_stream_resilience(Duration::from_millis(50), 2);
2684 let mut rx = harness
2685 .run_turn(NativeTurnInput {
2686 prompt_text: "hi".into(),
2687 system_prompt: None,
2688 attachments: vec![],
2689 cancel_token: None,
2690 prior_messages: vec![],
2691 context_path: None,
2692 })
2693 .await
2694 .unwrap();
2695
2696 let mut saw_error = false;
2697 while let Some(item) = rx.recv().await {
2698 match item {
2699 Err(NativeHarnessError::ModelNetwork(msg)) => {
2700 assert!(msg.contains("stalled"), "got {msg:?}");
2701 saw_error = true;
2702 break;
2703 }
2704 Err(other) => panic!("unexpected error variant: {other:?}"),
2705 Ok(_) => {}
2706 }
2707 }
2708 assert!(
2709 saw_error,
2710 "expected ModelNetwork stall error after budget exhausted"
2711 );
2712 assert_eq!(
2713 calls.load(Ordering::SeqCst),
2714 2,
2715 "two establishments (initial + one reconnect)"
2716 );
2717 }
2718
2719 #[tokio::test(start_paused = true)]
2720 async fn agent_loop_does_not_reconnect_after_stall_with_partial_output() {
2721 let model = StallingModelClient::new(vec![StallBehavior::EmitThenHang(vec![
2727 ModelChunk::TextDelta {
2728 msg_id: "m".into(),
2729 delta: "partial".into(),
2730 },
2731 ])]);
2732 let calls = model.calls.clone();
2733 let harness = AgentLoopHarness::new(model, MockToolRuntime::new())
2734 .with_stream_resilience(Duration::from_millis(50), 5);
2735 let mut rx = harness
2736 .run_turn(NativeTurnInput {
2737 prompt_text: "hi".into(),
2738 system_prompt: None,
2739 attachments: vec![],
2740 cancel_token: None,
2741 prior_messages: vec![],
2742 context_path: None,
2743 })
2744 .await
2745 .unwrap();
2746
2747 let mut text = String::new();
2748 let mut saw_error = false;
2749 while let Some(item) = rx.recv().await {
2750 match item {
2751 Ok(HarnessInternalEvent::AssistantTextChunk { delta, .. }) => text.push_str(&delta),
2752 Err(NativeHarnessError::ModelNetwork(_)) => {
2753 saw_error = true;
2754 break;
2755 }
2756 Err(other) => panic!("unexpected error variant: {other:?}"),
2757 Ok(_) => {}
2758 }
2759 }
2760 assert!(saw_error, "expected terminal ModelNetwork error");
2761 assert_eq!(
2762 text, "partial",
2763 "partial output delivered once, not replayed"
2764 );
2765 assert_eq!(
2766 calls.load(Ordering::SeqCst),
2767 1,
2768 "no reconnect once output has reached the user"
2769 );
2770 }
2771
2772 #[tokio::test]
2773 async fn agent_loop_accumulates_thinking_chunks_and_signature() {
2774 let model = StreamingFakeClient::new(vec![vec![
2783 ModelChunk::ThinkingDelta {
2784 thinking_id: "th_1".into(),
2785 delta: "let me think...".into(),
2786 signature: None,
2787 },
2788 ModelChunk::ThinkingDelta {
2789 thinking_id: "th_1".into(),
2790 delta: "".into(),
2791 signature: Some("sig_abc".into()),
2792 },
2793 ModelChunk::TextDelta {
2794 msg_id: "m1".into(),
2795 delta: "ok".into(),
2796 },
2797 ModelChunk::Done {
2798 stop_reason: "end_turn".into(),
2799 usage: None,
2800 },
2801 ]]);
2802 let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
2803 let mut rx = harness
2804 .run_turn(NativeTurnInput {
2805 prompt_text: "hi".into(),
2806 system_prompt: None,
2807 attachments: vec![],
2808 cancel_token: None,
2809 prior_messages: vec![],
2810 context_path: None,
2811 })
2812 .await
2813 .unwrap();
2814
2815 let mut thinking_chunks: Vec<String> = Vec::new();
2816 let mut text_chunks: Vec<String> = Vec::new();
2817 let mut saw_end = false;
2818 while let Some(item) = rx.recv().await {
2819 match item.unwrap() {
2820 HarnessInternalEvent::AssistantThinkingChunk { msg_id, delta } => {
2821 assert_eq!(msg_id, "thinking_native_0");
2822 thinking_chunks.push(delta);
2823 }
2824 HarnessInternalEvent::AssistantTextChunk { msg_id, delta } => {
2825 assert_eq!(msg_id, "msg_native_0");
2826 text_chunks.push(delta);
2827 }
2828 HarnessInternalEvent::TurnEnd { .. } => {
2829 saw_end = true;
2830 break;
2831 }
2832 other => panic!("unexpected event: {other:?}"),
2833 }
2834 }
2835 assert_eq!(thinking_chunks, vec!["let me think..."]);
2838 assert_eq!(text_chunks, vec!["ok"]);
2839 assert!(saw_end);
2840 }
2841
2842 #[tokio::test]
2843 async fn agent_loop_runs_tool_then_final_message() {
2844 let harness = AgentLoopHarness::new(
2845 ScriptedModelClient,
2846 MockToolRuntime::new().with_file("README.md", "hello"),
2847 );
2848 let mut rx = harness
2849 .run_turn(NativeTurnInput {
2850 prompt_text: "read README.md".into(),
2851 system_prompt: None,
2852 attachments: vec![],
2853 cancel_token: None,
2854 prior_messages: vec![],
2855 context_path: None,
2856 })
2857 .await
2858 .unwrap();
2859
2860 assert!(matches!(
2861 rx.recv().await.unwrap().unwrap(),
2862 HarnessInternalEvent::AssistantTextChunk { .. }
2863 ));
2864 assert!(matches!(
2865 rx.recv().await.unwrap().unwrap(),
2866 HarnessInternalEvent::ToolCall { ref name, .. } if name == "read"
2867 ));
2868 assert!(matches!(
2869 rx.recv().await.unwrap().unwrap(),
2870 HarnessInternalEvent::ToolResult { .. }
2871 ));
2872 assert!(matches!(
2873 rx.recv().await.unwrap().unwrap(),
2874 HarnessInternalEvent::AssistantTextChunk { .. }
2875 ));
2876 assert!(matches!(
2877 rx.recv().await.unwrap().unwrap(),
2878 HarnessInternalEvent::TurnEnd { .. }
2879 ));
2880 assert!(rx.recv().await.is_none());
2881 }
2882
2883 #[tokio::test]
2890 async fn agent_loop_turn_end_carries_full_message_history() {
2891 let model = QueueModelClient::new(vec![ModelResponse::Message {
2892 text: "second reply".into(),
2893 stop_reason: "end_turn".into(),
2894 usage: None,
2895 }]);
2896 let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
2897 let prior = vec![
2899 ChatMessage::User {
2900 content: "first prompt".into(),
2901 attachments: vec![],
2902 },
2903 ChatMessage::Assistant {
2904 text: Some("first reply".into()),
2905 tool_calls: vec![],
2906 thinking: None,
2907 usage: None,
2908 },
2909 ];
2910 let mut rx = harness
2911 .run_turn(NativeTurnInput {
2912 prompt_text: "second prompt".into(),
2913 system_prompt: None,
2914 attachments: vec![],
2915 cancel_token: None,
2916 prior_messages: prior,
2917 context_path: None,
2918 })
2919 .await
2920 .unwrap();
2921 let mut final_messages: Option<Vec<ChatMessage>> = None;
2922 while let Some(item) = rx.recv().await {
2923 if let HarnessInternalEvent::TurnEnd {
2924 final_messages: m, ..
2925 } = item.unwrap()
2926 {
2927 final_messages = Some(m);
2928 break;
2929 }
2930 }
2931 let msgs = final_messages.expect("TurnEnd carried final_messages");
2932 assert_eq!(msgs.len(), 4, "got {msgs:?}");
2934 match &msgs[0] {
2935 ChatMessage::User { content, .. } => assert_eq!(content, "first prompt"),
2936 other => panic!("msgs[0] not user-1: {other:?}"),
2937 }
2938 match &msgs[1] {
2939 ChatMessage::Assistant { text, .. } => {
2940 assert_eq!(text.as_deref(), Some("first reply"));
2941 }
2942 other => panic!("msgs[1] not assistant-1: {other:?}"),
2943 }
2944 match &msgs[2] {
2945 ChatMessage::User { content, .. } => assert_eq!(content, "second prompt"),
2946 other => panic!("msgs[2] not user-2: {other:?}"),
2947 }
2948 match &msgs[3] {
2949 ChatMessage::Assistant { text, .. } => {
2950 assert_eq!(text.as_deref(), Some("second reply"));
2951 }
2952 other => panic!("msgs[3] not assistant-2: {other:?}"),
2953 }
2954 }
2955
2956 #[derive(Clone)]
2961 struct ConcurrencyProbeRuntime {
2962 sleep_for: std::time::Duration,
2963 in_flight: Arc<AtomicUsize>,
2964 max_concurrency: Arc<AtomicUsize>,
2965 call_order: Arc<Mutex<Vec<String>>>,
2966 cancelled: Arc<AtomicUsize>,
2967 }
2968
2969 impl ConcurrencyProbeRuntime {
2970 fn new(sleep_for: std::time::Duration) -> Self {
2971 Self {
2972 sleep_for,
2973 in_flight: Arc::new(AtomicUsize::new(0)),
2974 max_concurrency: Arc::new(AtomicUsize::new(0)),
2975 call_order: Arc::new(Mutex::new(Vec::new())),
2976 cancelled: Arc::new(AtomicUsize::new(0)),
2977 }
2978 }
2979 }
2980
2981 #[async_trait]
2982 impl ToolRuntime for ConcurrencyProbeRuntime {
2983 fn specs(&self) -> Vec<crate::tools::ToolSpec> {
2984 vec![crate::tools::ToolSpec {
2985 name: "slow".into(),
2986 description: "sleeps".into(),
2987 input_schema: serde_json::json!({"type": "object"}),
2988 }]
2989 }
2990
2991 async fn invoke(
2992 &self,
2993 invocation: ToolInvocation,
2994 ) -> Result<ToolOutcome, ToolRuntimeError> {
2995 self.call_order.lock().unwrap().push(invocation.id.clone());
2996 let now = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
2997 let mut prev = self.max_concurrency.load(Ordering::SeqCst);
2998 while now > prev {
2999 match self.max_concurrency.compare_exchange(
3000 prev,
3001 now,
3002 Ordering::SeqCst,
3003 Ordering::SeqCst,
3004 ) {
3005 Ok(_) => break,
3006 Err(actual) => prev = actual,
3007 }
3008 }
3009 tokio::time::sleep(self.sleep_for).await;
3010 self.in_flight.fetch_sub(1, Ordering::SeqCst);
3011 Ok(ToolOutcome {
3012 output: Ok(serde_json::json!({"slept": true, "id": invocation.id})),
3013 attachments: vec![],
3014 })
3015 }
3016
3017 async fn invoke_cancellable(
3018 &self,
3019 invocation: ToolInvocation,
3020 cancel: Option<&CancellationToken>,
3021 ) -> Result<ToolOutcome, ToolRuntimeError> {
3022 self.call_order.lock().unwrap().push(invocation.id.clone());
3023 let now = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
3024 let mut prev = self.max_concurrency.load(Ordering::SeqCst);
3025 while now > prev {
3026 match self.max_concurrency.compare_exchange(
3027 prev,
3028 now,
3029 Ordering::SeqCst,
3030 Ordering::SeqCst,
3031 ) {
3032 Ok(_) => break,
3033 Err(actual) => prev = actual,
3034 }
3035 }
3036 if let Some(token) = cancel {
3037 tokio::select! {
3038 _ = token.cancelled() => {
3039 self.cancelled.fetch_add(1, Ordering::SeqCst);
3040 self.in_flight.fetch_sub(1, Ordering::SeqCst);
3041 Err(ToolRuntimeError::Runtime("cancelled".into()))
3042 }
3043 _ = tokio::time::sleep(self.sleep_for) => {
3044 self.in_flight.fetch_sub(1, Ordering::SeqCst);
3045 Ok(ToolOutcome {
3046 output: Ok(serde_json::json!({"slept": true, "id": invocation.id})),
3047 attachments: vec![],
3048 })
3049 }
3050 }
3051 } else {
3052 tokio::time::sleep(self.sleep_for).await;
3053 self.in_flight.fetch_sub(1, Ordering::SeqCst);
3054 Ok(ToolOutcome {
3055 output: Ok(serde_json::json!({"slept": true, "id": invocation.id})),
3056 attachments: vec![],
3057 })
3058 }
3059 }
3060 }
3061
3062 #[tokio::test]
3068 async fn agent_loop_runs_multi_tool_calls_concurrently() {
3069 let model = StreamingFakeClient::new(vec![
3072 vec![
3073 ModelChunk::ToolCallStart {
3074 id: "tc_a".into(),
3075 name: "slow".into(),
3076 },
3077 ModelChunk::ToolCallEnd {
3078 id: "tc_a".into(),
3079 input: Some(json!({})),
3080 },
3081 ModelChunk::ToolCallStart {
3082 id: "tc_b".into(),
3083 name: "slow".into(),
3084 },
3085 ModelChunk::ToolCallEnd {
3086 id: "tc_b".into(),
3087 input: Some(json!({})),
3088 },
3089 ModelChunk::ToolCallStart {
3090 id: "tc_c".into(),
3091 name: "slow".into(),
3092 },
3093 ModelChunk::ToolCallEnd {
3094 id: "tc_c".into(),
3095 input: Some(json!({})),
3096 },
3097 ModelChunk::Done {
3098 stop_reason: "tool_use".into(),
3099 usage: None,
3100 },
3101 ],
3102 vec![
3103 ModelChunk::TextDelta {
3104 msg_id: "remote".into(),
3105 delta: "done".into(),
3106 },
3107 ModelChunk::Done {
3108 stop_reason: "end_turn".into(),
3109 usage: None,
3110 },
3111 ],
3112 ]);
3113 let probe = ConcurrencyProbeRuntime::new(std::time::Duration::from_millis(80));
3114 let max_concurrency = probe.max_concurrency.clone();
3115 let harness = AgentLoopHarness::new(model, probe);
3116
3117 let start = std::time::Instant::now();
3118 let mut rx = harness
3119 .run_turn(NativeTurnInput {
3120 prompt_text: "go".into(),
3121 system_prompt: None,
3122 attachments: vec![],
3123 cancel_token: None,
3124 prior_messages: vec![],
3125 context_path: None,
3126 })
3127 .await
3128 .unwrap();
3129 let mut tool_results = 0;
3130 while let Some(item) = rx.recv().await {
3131 match item.unwrap() {
3132 HarnessInternalEvent::ToolResult { .. } => tool_results += 1,
3133 HarnessInternalEvent::TurnEnd { .. } => break,
3134 _ => {}
3135 }
3136 }
3137 let elapsed = start.elapsed();
3138 assert_eq!(
3140 tool_results, 3,
3141 "expected 3 tool results, got {tool_results}"
3142 );
3143 assert_eq!(
3145 max_concurrency.load(Ordering::SeqCst),
3146 3,
3147 "expected max concurrency 3 (parallel dispatch), got {}",
3148 max_concurrency.load(Ordering::SeqCst)
3149 );
3150 assert!(
3154 elapsed < std::time::Duration::from_millis(200),
3155 "elapsed {elapsed:?} suggests sequential execution"
3156 );
3157 }
3158
3159 #[tokio::test]
3163 async fn agent_loop_cancels_in_flight_tool_calls() {
3164 let model = StreamingFakeClient::new(vec![vec![
3165 ModelChunk::ToolCallStart {
3166 id: "tc_slow".into(),
3167 name: "slow".into(),
3168 },
3169 ModelChunk::ToolCallEnd {
3170 id: "tc_slow".into(),
3171 input: Some(json!({})),
3172 },
3173 ModelChunk::Done {
3174 stop_reason: "tool_use".into(),
3175 usage: None,
3176 },
3177 ]]);
3178 let probe = ConcurrencyProbeRuntime::new(std::time::Duration::from_secs(5));
3181 let cancelled_count = probe.cancelled.clone();
3182 let harness = AgentLoopHarness::new(model, probe);
3183
3184 let cancel = CancellationToken::new();
3185 let cancel_for_input = cancel.clone();
3186 let mut rx = harness
3187 .run_turn(NativeTurnInput {
3188 prompt_text: "go".into(),
3189 system_prompt: None,
3190 attachments: vec![],
3191 cancel_token: Some(cancel_for_input),
3192 prior_messages: vec![],
3193 context_path: None,
3194 })
3195 .await
3196 .unwrap();
3197
3198 tokio::time::sleep(std::time::Duration::from_millis(30)).await;
3200 cancel.cancel();
3201
3202 let start = std::time::Instant::now();
3203 let mut saw_interrupt = false;
3204 while let Some(item) = rx.recv().await {
3205 if let HarnessInternalEvent::TurnEnd { stop_reason, .. } = item.unwrap() {
3206 assert_eq!(stop_reason, "interrupt");
3207 saw_interrupt = true;
3208 break;
3209 }
3210 }
3211 let elapsed = start.elapsed();
3212 assert!(saw_interrupt, "must see interrupt TurnEnd");
3213 assert!(
3214 elapsed < std::time::Duration::from_millis(200),
3215 "cancel propagation took too long: {elapsed:?}"
3216 );
3217 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
3218 while cancelled_count.load(Ordering::SeqCst) == 0 && tokio::time::Instant::now() < deadline
3219 {
3220 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3221 }
3222 assert_eq!(
3223 cancelled_count.load(Ordering::SeqCst),
3224 1,
3225 "tool runtime must observe the cancellation token"
3226 );
3227 }
3228
3229 #[derive(Clone)]
3230 struct DefaultCancellationProbe {
3231 started: Arc<tokio::sync::Notify>,
3232 completed: Arc<std::sync::atomic::AtomicBool>,
3233 }
3234
3235 #[async_trait]
3236 impl ToolRuntime for DefaultCancellationProbe {
3237 fn specs(&self) -> Vec<crate::tools::ToolSpec> {
3238 vec![crate::tools::ToolSpec {
3239 name: "slow".into(),
3240 description: "records a delayed side effect".into(),
3241 input_schema: serde_json::json!({"type": "object"}),
3242 }]
3243 }
3244
3245 async fn invoke(
3246 &self,
3247 _invocation: ToolInvocation,
3248 ) -> Result<ToolOutcome, ToolRuntimeError> {
3249 self.started.notify_one();
3250 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
3251 self.completed.store(true, Ordering::SeqCst);
3252 Ok(ToolOutcome {
3253 output: Ok(serde_json::json!({"completed": true})),
3254 attachments: vec![],
3255 })
3256 }
3257 }
3258
3259 #[tokio::test]
3264 async fn agent_loop_cancel_drops_default_tool_future() {
3265 let model = StreamingFakeClient::new(vec![vec![
3266 ModelChunk::ToolCallStart {
3267 id: "tc_slow".into(),
3268 name: "slow".into(),
3269 },
3270 ModelChunk::ToolCallEnd {
3271 id: "tc_slow".into(),
3272 input: Some(json!({})),
3273 },
3274 ModelChunk::Done {
3275 stop_reason: "tool_use".into(),
3276 usage: None,
3277 },
3278 ]]);
3279 let started = Arc::new(tokio::sync::Notify::new());
3280 let completed = Arc::new(std::sync::atomic::AtomicBool::new(false));
3281 let runtime = DefaultCancellationProbe {
3282 started: started.clone(),
3283 completed: completed.clone(),
3284 };
3285 let cancel = CancellationToken::new();
3286 let harness = AgentLoopHarness::new(model, runtime);
3287 let mut rx = harness
3288 .run_turn(NativeTurnInput {
3289 prompt_text: "go".into(),
3290 system_prompt: None,
3291 attachments: vec![],
3292 cancel_token: Some(cancel.clone()),
3293 prior_messages: vec![],
3294 context_path: None,
3295 })
3296 .await
3297 .unwrap();
3298
3299 started.notified().await;
3300 cancel.cancel();
3301
3302 let mut saw_interrupt = false;
3303 while let Some(item) = rx.recv().await {
3304 if let HarnessInternalEvent::TurnEnd { stop_reason, .. } = item.unwrap() {
3305 assert_eq!(stop_reason, "interrupt");
3306 saw_interrupt = true;
3307 break;
3308 }
3309 }
3310 assert!(saw_interrupt);
3311 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
3312 assert!(
3313 !completed.load(Ordering::SeqCst),
3314 "cancelled tool produced a side effect after TurnEnd"
3315 );
3316 }
3317
3318 #[tokio::test]
3323 async fn agent_loop_cancel_during_tools_yields_clean_history() {
3324 let model = StreamingFakeClient::new(vec![vec![
3325 ModelChunk::ToolCallStart {
3326 id: "tc_a".into(),
3327 name: "slow".into(),
3328 },
3329 ModelChunk::ToolCallEnd {
3330 id: "tc_a".into(),
3331 input: Some(json!({})),
3332 },
3333 ModelChunk::ToolCallStart {
3334 id: "tc_b".into(),
3335 name: "slow".into(),
3336 },
3337 ModelChunk::ToolCallEnd {
3338 id: "tc_b".into(),
3339 input: Some(json!({})),
3340 },
3341 ModelChunk::Done {
3342 stop_reason: "tool_use".into(),
3343 usage: None,
3344 },
3345 ]]);
3346 let probe = ConcurrencyProbeRuntime::new(std::time::Duration::from_secs(3));
3347 let harness = AgentLoopHarness::new(model, probe);
3348
3349 let cancel = CancellationToken::new();
3350 let cancel_for_input = cancel.clone();
3351 let mut rx = harness
3352 .run_turn(NativeTurnInput {
3353 prompt_text: "go".into(),
3354 system_prompt: None,
3355 attachments: vec![],
3356 cancel_token: Some(cancel_for_input),
3357 prior_messages: vec![],
3358 context_path: None,
3359 })
3360 .await
3361 .unwrap();
3362
3363 tokio::time::sleep(std::time::Duration::from_millis(30)).await;
3364 cancel.cancel();
3365
3366 let mut final_msgs = None;
3367 while let Some(item) = rx.recv().await {
3368 if let HarnessInternalEvent::TurnEnd { final_messages, .. } = item.unwrap() {
3369 final_msgs = Some(final_messages);
3370 break;
3371 }
3372 }
3373 let msgs = final_msgs.expect("interrupt TurnEnd");
3374 assert_eq!(msgs.len(), 2, "expected 2 messages, got {msgs:?}");
3377 match &msgs[1] {
3378 ChatMessage::Assistant { tool_calls, .. } => {
3379 assert_eq!(tool_calls.len(), 2);
3380 assert_eq!(tool_calls[0].id, "tc_a");
3381 assert_eq!(tool_calls[1].id, "tc_b");
3382 }
3383 other => panic!("msgs[1] not assistant: {other:?}"),
3384 }
3385 }
3386}